From 0ad43a69814f8887a2780ce24da13f0b7eeb99ff Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Sat, 14 Mar 2026 17:41:35 +0300 Subject: [PATCH] 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 --- src/commands/pull.ts | 5 +- src/commands/update.ts | 218 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 34 ++++++- 3 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 src/commands/update.ts diff --git a/src/commands/pull.ts b/src/commands/pull.ts index dc0b957..ba3b3a9 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -252,7 +252,10 @@ export async function pullCommand(force = false): Promise { writeEndpointToDisk(ep, endpointDir); - const hash = computeEndpointHash(ep); + // Хеш считаем от того, что реально записалось на диск, + // чтобы он совпадал при последующем readEndpointFromDisk в status + const diskEp = readEndpointFromDisk(endpointDir); + const hash = computeEndpointHash(diskEp || ep); newState.endpoints[ep.id] = { updated_at: ep.updated_at, folder_path: path.relative(root, endpointDir), diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..067c345 --- /dev/null +++ b/src/commands/update.ts @@ -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 { + 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; +} + +/** + * Фоновая проверка обновлений — вызывается при каждой команде. + * Не блокирует работу, просто выводит подсказку в конце. + */ +export async function checkForUpdateBackground(currentVersion: string): Promise { + 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 { + 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 { + 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}\\`)); + } +} diff --git a/src/index.ts b/src/index.ts index 8032928..7e8677a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,16 @@ import { initCommand } from './commands/init'; import { pullCommand } from './commands/pull'; import { pushCommand } from './commands/push'; import { statusCommand } from './commands/status'; +import { updateCommand, updateApplyCommand, checkForUpdateBackground } from './commands/update'; + +const VERSION = '1.0.0'; const program = new Command(); program .name('kisync') .description('CLI tool for syncing local folders with KIS API Builder') - .version('1.0.0'); + .version(VERSION); program .command('init') @@ -20,8 +23,9 @@ program .action(async () => { try { await initCommand(); + await checkForUpdateBackground(VERSION); } catch (err: any) { - console.error(chalk.red(`Error: ${err.message}`)); + console.error(chalk.red(`Ошибка: ${err.message}`)); process.exit(1); } }); @@ -33,8 +37,9 @@ program .action(async (opts) => { try { await pullCommand(opts.force); + await checkForUpdateBackground(VERSION); } catch (err: any) { - console.error(chalk.red(`Error: ${err.message}`)); + console.error(chalk.red(`Ошибка: ${err.message}`)); process.exit(1); } }); @@ -46,8 +51,9 @@ program .action(async (opts) => { try { await pushCommand(opts.force); + await checkForUpdateBackground(VERSION); } catch (err: any) { - console.error(chalk.red(`Error: ${err.message}`)); + console.error(chalk.red(`Ошибка: ${err.message}`)); process.exit(1); } }); @@ -58,8 +64,26 @@ program .action(async () => { try { await statusCommand(); + await checkForUpdateBackground(VERSION); } catch (err: any) { - console.error(chalk.red(`Error: ${err.message}`)); + console.error(chalk.red(`Ошибка: ${err.message}`)); + process.exit(1); + } + }); + +program + .command('update') + .description('Проверить обновления и обновить kisync') + .option('--apply', 'Скачать и установить обновление') + .action(async (opts) => { + try { + if (opts.apply) { + await updateApplyCommand(VERSION); + } else { + await updateCommand(VERSION); + } + } catch (err: any) { + console.error(chalk.red(`Ошибка: ${err.message}`)); process.exit(1); } });