Add self-update command and fix hash mismatch after pull

- kisync update: show available releases from Gitea
- kisync update --apply: download and replace exe in-place
- Background update check after every command (non-blocking)
- Fix: compute hash from disk-read object (not server object) to prevent
  false "modified" status after pull due to line-ending differences

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 17:41:35 +03:00
parent 9f041c2d3d
commit 0ad43a6981
3 changed files with 251 additions and 6 deletions

218
src/commands/update.ts Normal file
View File

@@ -0,0 +1,218 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import chalk from 'chalk';
import fetch from 'node-fetch';
const GITEA_API = 'https://gitea.esh-service.ru/api/v1';
const REPO_OWNER = 'public';
const REPO_NAME = 'api_builder_cli_client';
const EXE_NAME = 'kisync.exe';
interface Release {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
assets: {
id: number;
name: string;
size: number;
browser_download_url: string;
}[];
}
function parseVersion(tag: string): number[] {
return tag.replace(/^v/, '').split('.').map(Number);
}
function isNewer(remote: string, local: string): boolean {
const r = parseVersion(remote);
const l = parseVersion(local);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
const rv = r[i] || 0;
const lv = l[i] || 0;
if (rv > lv) return true;
if (rv < lv) return false;
}
return false;
}
async function fetchReleases(): Promise<Release[]> {
const url = `${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json() as Promise<Release[]>;
}
/**
* Фоновая проверка обновлений — вызывается при каждой команде.
* Не блокирует работу, просто выводит подсказку в конце.
*/
export async function checkForUpdateBackground(currentVersion: string): Promise<void> {
try {
const releases = await fetchReleases();
const latest = releases.find(r =>
r.assets.some(a => a.name.toLowerCase() === EXE_NAME.toLowerCase())
);
if (latest && isNewer(latest.tag_name, currentVersion)) {
console.log('');
console.log(
chalk.yellow(` Доступна новая версия: ${latest.tag_name} (текущая: v${currentVersion})`)
);
console.log(
chalk.yellow(` Обновить: kisync update --apply`)
);
}
} catch {
// Тихо игнорируем — фоновая проверка не должна мешать работе
}
}
/**
* kisync update — показать список релизов
*/
export async function updateCommand(currentVersion: string): Promise<void> {
console.log(chalk.gray('Проверка обновлений...'));
console.log(chalk.gray(`Текущая версия: v${currentVersion}\n`));
let releases: Release[];
try {
releases = await fetchReleases();
} catch (err: any) {
throw new Error(`Не удалось подключиться к серверу обновлений: ${err.message}`);
}
if (releases.length === 0) {
console.log(chalk.green('Релизы не найдены.'));
return;
}
console.log(chalk.bold('Доступные релизы:\n'));
for (const rel of releases) {
const tag = rel.tag_name;
const date = new Date(rel.published_at).toLocaleDateString('ru-RU');
const isCurrent = tag === `v${currentVersion}` || tag === currentVersion;
const marker = isCurrent ? chalk.green(' ← текущая') : '';
const newer = isNewer(tag, currentVersion);
const prefix = newer ? chalk.cyan(' ↑') : chalk.gray(' ');
console.log(`${prefix} ${chalk.bold(tag)} ${chalk.gray(date)}${marker}`);
if (rel.body) {
const lines = rel.body.trim().split('\n').slice(0, 3);
for (const line of lines) {
console.log(chalk.gray(` ${line}`));
}
}
if (rel.assets.length > 0) {
for (const asset of rel.assets) {
const sizeMb = (asset.size / (1024 * 1024)).toFixed(1);
console.log(chalk.gray(` ${asset.name} (${sizeMb} МБ)`));
}
}
console.log('');
}
// Найти последний релиз с exe
const latest = releases.find(r =>
r.assets.some(a => a.name.toLowerCase() === EXE_NAME.toLowerCase())
);
if (!latest) {
console.log(chalk.yellow('Ни один релиз не содержит kisync.exe.'));
return;
}
if (!isNewer(latest.tag_name, currentVersion)) {
console.log(chalk.green('У вас установлена последняя версия.'));
return;
}
console.log(
chalk.cyan(`Новая версия: ${latest.tag_name} (текущая: v${currentVersion})`)
);
console.log(
chalk.gray(`Для обновления выполните: kisync update --apply\n`)
);
}
/**
* kisync update --apply — скачать и заменить exe
*/
export async function updateApplyCommand(currentVersion: string): Promise<void> {
console.log(chalk.gray('Проверка обновлений...'));
let releases: Release[];
try {
releases = await fetchReleases();
} catch (err: any) {
throw new Error(`Не удалось подключиться к серверу обновлений: ${err.message}`);
}
const latest = releases.find(r =>
r.assets.some(a => a.name.toLowerCase() === EXE_NAME.toLowerCase())
);
if (!latest) {
throw new Error('Ни один релиз не содержит kisync.exe.');
}
if (!isNewer(latest.tag_name, currentVersion)) {
console.log(chalk.green(`Уже установлена последняя версия (v${currentVersion}).`));
return;
}
const asset = latest.assets.find(
a => a.name.toLowerCase() === EXE_NAME.toLowerCase()
)!;
console.log(chalk.cyan(`Скачивание ${latest.tag_name}...`));
console.log(chalk.gray(` ${asset.browser_download_url}`));
const res = await fetch(asset.browser_download_url);
if (!res.ok) {
throw new Error(`Ошибка загрузки: HTTP ${res.status}`);
}
const buffer = await res.buffer();
console.log(chalk.gray(` Загружено ${(buffer.length / (1024 * 1024)).toFixed(1)} МБ`));
// Путь к текущему exe
const currentExe = process.execPath;
const exeDir = path.dirname(currentExe);
const exeName = path.basename(currentExe);
// На Windows нельзя перезаписать работающий exe — переименуем в .old
const oldExe = path.join(exeDir, `${exeName}.old`);
const newExe = path.join(exeDir, exeName);
try {
if (fs.existsSync(oldExe)) {
fs.unlinkSync(oldExe);
}
fs.renameSync(currentExe, oldExe);
fs.writeFileSync(newExe, buffer);
console.log(chalk.green(`\nОбновлено до ${latest.tag_name}!`));
console.log(chalk.gray(` Резервная копия: ${oldExe}`));
console.log(chalk.gray(` Перезапустите kisync для использования новой версии.`));
} catch (err: any) {
// Попытка восстановить при ошибке
if (fs.existsSync(oldExe) && !fs.existsSync(newExe)) {
try { fs.renameSync(oldExe, newExe); } catch {}
}
// Запасной вариант — сохранить во временную папку
const tmpPath = path.join(os.tmpdir(), EXE_NAME);
fs.writeFileSync(tmpPath, buffer);
console.log(chalk.yellow(`\nНе удалось заменить exe: ${err.message}`));
console.log(chalk.yellow(`Новая версия сохранена: ${tmpPath}`));
console.log(chalk.yellow(`Скопируйте вручную в: ${exeDir}\\`));
}
}