#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseVersion, versionCompatibility } from '../src/shared/versions.js'; const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend']; const VERSION_FILE = 'src/shared/versions.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const aliases = { mac: 'macClient', 'mac-client': 'macClient', client: 'gatewayClient', 'gateway-client': 'gatewayClient', backend: 'gatewayBackend', 'gateway-backend': 'gatewayBackend', }; export function versionsFromSource(source) { return Object.fromEntries(COMPONENTS.map((component) => { const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source); if (!match) throw new Error(`Не найдена версия ${component}`); return [component, match[1]]; })); } export function affectedComponents(files) { const affected = new Set(); const add = (...components) => components.forEach((component) => affected.add(component)); for (const file of files) { if (file === VERSION_FILE) continue; if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS); else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) { add('macClient', 'gatewayClient'); } else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend'); else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) { add('macClient'); } else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) { add('gatewayBackend'); } } return COMPONENTS.filter((component) => affected.has(component)); } function formatVersion({ major, minor, hotfix }) { return `${major}.${minor}.${hotfix}`; } export function bumpVersions(versions, level, requested = []) { const current = Object.fromEntries(COMPONENTS.map((component) => { const parsed = parseVersion(versions[component]); if (!parsed) throw new Error(`Некорректная версия ${component}: ${versions[component]}`); return [component, parsed]; })); if (level === 'major') { const major = Math.max(...COMPONENTS.map((component) => current[component].major)) + 1; return Object.fromEntries(COMPONENTS.map((component) => [component, `${major}.0.0`])); } const targets = new Set(requested.map((target) => aliases[target] || target)); if (!targets.size) throw new Error(`${level} требует хотя бы один компонент`); for (const target of targets) { if (!COMPONENTS.includes(target)) throw new Error(`Неизвестный компонент: ${target}`); } if (level === 'minor' && (targets.has('gatewayClient') || targets.has('gatewayBackend'))) { targets.add('gatewayClient'); targets.add('gatewayBackend'); } if (!['minor', 'hotfix'].includes(level)) throw new Error(`Неизвестный уровень: ${level}`); const next = { ...versions }; if (level === 'minor' && targets.has('gatewayClient')) { const minor = Math.max(current.gatewayClient.minor, current.gatewayBackend.minor) + 1; next.gatewayClient = `${current.gatewayClient.major}.${minor}.0`; next.gatewayBackend = `${current.gatewayBackend.major}.${minor}.0`; targets.delete('gatewayClient'); targets.delete('gatewayBackend'); } for (const target of targets) { const value = current[target]; next[target] = level === 'minor' ? `${value.major}.${value.minor + 1}.0` : formatVersion({ ...value, hotfix: value.hotfix + 1 }); } return next; } function git(args) { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } function changedFiles(base) { const tracked = git(['diff', '--name-only', base, '--']).split('\n'); const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n'); return [...new Set([...tracked, ...untracked].filter(Boolean))]; } function baselineVersions(base) { try { return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`])); } catch { return null; } } function compareVersions(before, after) { const left = parseVersion(before); const right = parseVersion(after); if (!left || !right) return -1; for (const key of ['major', 'minor', 'hotfix']) { if (right[key] !== left[key]) return right[key] > left[key] ? 1 : -1; } return 0; } function validateCompatibility(versions) { const compatibility = versionCompatibility(versions); if (!compatibility.major) throw new Error('У всех компонентов должен совпадать major'); if (!compatibility.gateway) throw new Error('Gateway client и backend должны совпадать по major.minor'); } function writeVersions(versions) { const file = path.join(root, VERSION_FILE); let source = fs.readFileSync(file, 'utf8'); for (const component of COMPONENTS) { source = source.replace( new RegExp(`(${component}:\\s*')\\d+\\.\\d+\\.\\d+(')`), `$1${versions[component]}$2`, ); } fs.writeFileSync(file, source); } function printVersions(versions) { for (const component of COMPONENTS) console.log(`${component}: ${versions[component]}`); } function main([command = 'check', ...args]) { const source = fs.readFileSync(path.join(root, VERSION_FILE), 'utf8'); const current = versionsFromSource(source); validateCompatibility(current); if (command === 'bump') { const next = bumpVersions(current, args[0], args.slice(1)); validateCompatibility(next); writeVersions(next); printVersions(next); return; } const base = args[0] || 'HEAD'; const affected = affectedComponents(changedFiles(base)); if (command === 'affected') { console.log(affected.length ? affected.join('\n') : 'Нет изменений, требующих bump.'); return; } if (command !== 'check') throw new Error(`Неизвестная команда: ${command}`); const baseline = baselineVersions(base); if (!baseline) { console.log('Version contract создаётся впервые; baseline для bump отсутствует.'); return; } const missing = affected.filter((component) => compareVersions(baseline[component], current[component]) <= 0); if (missing.length) throw new Error(`Не повышена версия: ${missing.join(', ')}`); console.log(affected.length ? `Version check: ${affected.join(', ')}` : 'Version check: bump не требуется.'); } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { try { main(process.argv.slice(2)); } catch (error) { console.error(`[harbor-version] ${error.message}`); process.exitCode = 1; } }