mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
feat: add configuration backup, sync, and version checker services
- Implemented ConfigBackupService for backing up local configuration files. - Added ConfigSyncService to download configuration files from GitHub with remote-first conflict resolution. - Created VersionChecker to check application version against the latest GitHub release with caching. - Introduced security validation utilities for input validation to prevent common vulnerabilities. - Developed utility functions to start and stop Docusaurus documentation server.
This commit is contained in:
212
ccw/src/core/services/config-backup.ts
Normal file
212
ccw/src/core/services/config-backup.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Config Backup Service
|
||||
* Handles backup of local configuration files (.claude, .codex, .gemini, .qwen)
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, stat, copyFile, rm, access } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
export interface BackupOptions {
|
||||
/** Configuration directories to backup (default: ['.claude']) */
|
||||
configDirs?: string[];
|
||||
/** Custom backup name (default: auto-generated timestamp) */
|
||||
backupName?: string;
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean;
|
||||
backupPath?: string;
|
||||
fileCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
createdAt: Date;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export class ConfigBackupService {
|
||||
private ccwDir: string;
|
||||
private backupDir: string;
|
||||
|
||||
constructor() {
|
||||
this.ccwDir = join(homedir(), '.ccw');
|
||||
this.backupDir = join(this.ccwDir, 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of configuration directories
|
||||
* @param options - Backup options
|
||||
* @returns Backup result with path and file count
|
||||
*/
|
||||
async createBackup(options: BackupOptions = {}): Promise<BackupResult> {
|
||||
const { configDirs = ['.claude'], backupName } = options;
|
||||
|
||||
try {
|
||||
// Create backup directory
|
||||
await mkdir(this.backupDir, { recursive: true });
|
||||
|
||||
// Generate backup name
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
const backupNameFinal = backupName || `backup-${timestamp}`;
|
||||
const backupPath = join(this.backupDir, backupNameFinal);
|
||||
|
||||
await mkdir(backupPath, { recursive: true });
|
||||
|
||||
let fileCount = 0;
|
||||
|
||||
// Backup each config directory
|
||||
for (const configDir of configDirs) {
|
||||
const sourcePath = join(this.ccwDir, configDir);
|
||||
const targetPath = join(backupPath, configDir);
|
||||
|
||||
// Check if source exists
|
||||
try {
|
||||
await access(sourcePath);
|
||||
} catch {
|
||||
continue; // Skip if doesn't exist
|
||||
}
|
||||
|
||||
// Copy directory recursively
|
||||
await this.copyDirectory(sourcePath, targetPath);
|
||||
|
||||
// Count files
|
||||
const files = await this.countFiles(targetPath);
|
||||
fileCount += files;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backupPath,
|
||||
fileCount
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
fileCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available backups
|
||||
* @returns Array of backup information sorted by creation date (newest first)
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
const entries = await readdir(this.backupDir, { withFileTypes: true });
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const backupPath = join(this.backupDir, entry.name);
|
||||
const stats = await stat(backupPath);
|
||||
const fileCount = await this.countFiles(backupPath);
|
||||
|
||||
backups.push({
|
||||
name: entry.name,
|
||||
path: backupPath,
|
||||
createdAt: stats.mtime,
|
||||
fileCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific backup
|
||||
* @param backupName - Name of the backup to delete
|
||||
* @returns Success status
|
||||
*/
|
||||
async deleteBackup(backupName: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const backupPath = join(this.backupDir, backupName);
|
||||
await rm(backupPath, { recursive: true, force: true });
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a backup to the original location
|
||||
* @param backupName - Name of the backup to restore
|
||||
* @returns Success status
|
||||
*/
|
||||
async restoreBackup(backupName: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const backupPath = join(this.backupDir, backupName);
|
||||
const entries = await readdir(backupPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const sourcePath = join(backupPath, entry.name);
|
||||
const targetPath = join(this.ccwDir, entry.name);
|
||||
|
||||
// Copy directory back to original location
|
||||
await this.copyDirectory(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy directory recursively
|
||||
* @param src - Source directory path
|
||||
* @param dest - Destination directory path
|
||||
*/
|
||||
private async copyDirectory(src: string, dest: string): Promise<void> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
const entries = await readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(src, entry.name);
|
||||
const destPath = join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
await copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count files in a directory recursively
|
||||
* @param dir - Directory path
|
||||
* @returns Number of files
|
||||
*/
|
||||
private async countFiles(dir: string): Promise<number> {
|
||||
let count = 0;
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
count += await this.countFiles(join(dir, entry.name));
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
330
ccw/src/core/services/config-sync.ts
Normal file
330
ccw/src/core/services/config-sync.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Config Sync Service
|
||||
* Downloads configuration files from GitHub using remote-first conflict resolution
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
validateConfigDirs,
|
||||
validateGitHubParams,
|
||||
VALID_CONFIG_DIRS,
|
||||
type ValidConfigDir
|
||||
} from '../../utils/security-validation.js';
|
||||
|
||||
/**
|
||||
* Default GitHub repository configuration for remote config sync
|
||||
*/
|
||||
const DEFAULT_GITHUB_CONFIG = {
|
||||
owner: 'dyw0830',
|
||||
repo: 'ccw',
|
||||
branch: 'main',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default config directories to sync
|
||||
* Uses whitelist from security-validation
|
||||
*/
|
||||
const DEFAULT_CONFIG_DIRS: ValidConfigDir[] = ['.claude'];
|
||||
|
||||
/**
|
||||
* Common configuration files to sync from each config directory
|
||||
*/
|
||||
const COMMON_CONFIG_FILES = [
|
||||
'settings.json',
|
||||
'config.json',
|
||||
'CLAUDE.md',
|
||||
'cli-tools.json',
|
||||
'guidelines.json',
|
||||
'prompts.json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sync result interface
|
||||
*/
|
||||
export interface ConfigSyncResult {
|
||||
success: boolean;
|
||||
syncedFiles: string[];
|
||||
errors: string[];
|
||||
skippedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Config sync options interface
|
||||
*/
|
||||
export interface ConfigSyncOptions {
|
||||
/** GitHub repository owner (default: 'dyw0830') */
|
||||
owner?: string;
|
||||
/** GitHub repository name (default: 'ccw') */
|
||||
repo?: string;
|
||||
/** Git branch (default: 'main') */
|
||||
branch?: string;
|
||||
/** Config directories to sync (default: ['.claude']) */
|
||||
configDirs?: string[];
|
||||
/** Target base directory (default: ~/.ccw) */
|
||||
baseDir?: string;
|
||||
/** Remote-first: overwrite local files (default: true) */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config Sync Service
|
||||
* Downloads configuration files from GitHub with remote-first conflict resolution
|
||||
*/
|
||||
export class ConfigSyncService {
|
||||
/**
|
||||
* Sync configuration files from GitHub
|
||||
* @param options - Sync options
|
||||
* @returns Sync result with status, files synced, and any errors
|
||||
*/
|
||||
async syncConfig(options: ConfigSyncOptions = {}): Promise<ConfigSyncResult> {
|
||||
const {
|
||||
owner = DEFAULT_GITHUB_CONFIG.owner,
|
||||
repo = DEFAULT_GITHUB_CONFIG.repo,
|
||||
branch = DEFAULT_GITHUB_CONFIG.branch,
|
||||
configDirs = DEFAULT_CONFIG_DIRS,
|
||||
baseDir = join(homedir(), '.ccw'),
|
||||
overwrite = true,
|
||||
} = options;
|
||||
|
||||
// SECURITY: Validate all inputs before processing
|
||||
try {
|
||||
// Validate GitHub parameters (SSRF protection)
|
||||
validateGitHubParams({ owner, repo, branch });
|
||||
|
||||
// Validate config directories (path traversal protection)
|
||||
validateConfigDirs(configDirs);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
syncedFiles: [],
|
||||
errors: [error instanceof Error ? error.message : String(error)],
|
||||
skippedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const results: ConfigSyncResult = {
|
||||
success: true,
|
||||
syncedFiles: [],
|
||||
errors: [],
|
||||
skippedFiles: [],
|
||||
};
|
||||
|
||||
for (const configDir of configDirs) {
|
||||
try {
|
||||
const dirResult = await this.syncConfigDirectory(configDir, {
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
baseDir,
|
||||
overwrite,
|
||||
});
|
||||
|
||||
results.syncedFiles.push(...dirResult.syncedFiles);
|
||||
results.errors.push(...dirResult.errors);
|
||||
results.skippedFiles.push(...dirResult.skippedFiles);
|
||||
|
||||
if (!dirResult.success) {
|
||||
results.success = false;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(`${configDir}: ${message}`);
|
||||
results.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single config directory
|
||||
*/
|
||||
private async syncConfigDirectory(
|
||||
configDir: string,
|
||||
options: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseDir: string;
|
||||
overwrite: boolean;
|
||||
}
|
||||
): Promise<ConfigSyncResult> {
|
||||
const { owner, repo, branch, baseDir, overwrite } = options;
|
||||
const result: ConfigSyncResult = {
|
||||
success: true,
|
||||
syncedFiles: [],
|
||||
errors: [],
|
||||
skippedFiles: [],
|
||||
};
|
||||
|
||||
const localPath = join(baseDir, configDir);
|
||||
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${configDir}`;
|
||||
|
||||
// Ensure local directory exists
|
||||
await fs.mkdir(localPath, { recursive: true });
|
||||
|
||||
// Try to sync each common config file
|
||||
for (const file of COMMON_CONFIG_FILES) {
|
||||
const fileUrl = `${baseUrl}/${file}`;
|
||||
const localFilePath = join(localPath, file);
|
||||
|
||||
try {
|
||||
// Check if remote file exists
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
// File doesn't exist on remote, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// Check if local file exists
|
||||
const localExists = await this.fileExists(localFilePath);
|
||||
|
||||
if (localExists && !overwrite) {
|
||||
result.skippedFiles.push(localFilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write remote content to local file (remote-first)
|
||||
await fs.writeFile(localFilePath, content, 'utf-8');
|
||||
result.syncedFiles.push(localFilePath);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
result.errors.push(`${file}: ${message}`);
|
||||
result.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available config files from remote directory
|
||||
* @param configDir - Config directory name
|
||||
* @param options - GitHub options
|
||||
* @returns List of available files
|
||||
*/
|
||||
async listRemoteFiles(
|
||||
configDir: string,
|
||||
options: Partial<Pick<ConfigSyncOptions, 'owner' | 'repo' | 'branch'>> = {}
|
||||
): Promise<string[]> {
|
||||
const {
|
||||
owner = DEFAULT_GITHUB_CONFIG.owner,
|
||||
repo = DEFAULT_GITHUB_CONFIG.repo,
|
||||
branch = DEFAULT_GITHUB_CONFIG.branch,
|
||||
} = options;
|
||||
|
||||
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${configDir}`;
|
||||
const availableFiles: string[] = [];
|
||||
|
||||
for (const file of COMMON_CONFIG_FILES) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/${file}`);
|
||||
if (response.ok) {
|
||||
availableFiles.push(file);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or network error, skip
|
||||
}
|
||||
}
|
||||
|
||||
return availableFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status - compare local and remote files
|
||||
* @param options - Sync options
|
||||
* @returns Status comparison result
|
||||
*/
|
||||
async getSyncStatus(options: ConfigSyncOptions = {}): Promise<{
|
||||
localOnly: string[];
|
||||
remoteOnly: string[];
|
||||
synced: string[];
|
||||
}> {
|
||||
const {
|
||||
owner = DEFAULT_GITHUB_CONFIG.owner,
|
||||
repo = DEFAULT_GITHUB_CONFIG.repo,
|
||||
branch = DEFAULT_GITHUB_CONFIG.branch,
|
||||
configDirs = DEFAULT_CONFIG_DIRS,
|
||||
baseDir = join(homedir(), '.ccw'),
|
||||
} = options;
|
||||
|
||||
// SECURITY: Validate inputs
|
||||
try {
|
||||
validateGitHubParams({ owner, repo, branch });
|
||||
validateConfigDirs(configDirs);
|
||||
} catch (error) {
|
||||
throw error; // Re-throw validation errors
|
||||
}
|
||||
|
||||
const status = {
|
||||
localOnly: [] as string[],
|
||||
remoteOnly: [] as string[],
|
||||
synced: [] as string[],
|
||||
};
|
||||
|
||||
for (const configDir of configDirs) {
|
||||
const localPath = join(baseDir, configDir);
|
||||
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${configDir}`;
|
||||
|
||||
const remoteFiles = await this.listRemoteFiles(configDir, { owner, repo, branch });
|
||||
const localFiles = await this.listLocalFiles(localPath);
|
||||
|
||||
for (const file of remoteFiles) {
|
||||
const localFilePath = join(localPath, file);
|
||||
if (localFiles.includes(file)) {
|
||||
status.synced.push(localFilePath);
|
||||
} else {
|
||||
status.remoteOnly.push(localFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of localFiles) {
|
||||
if (!remoteFiles.includes(file)) {
|
||||
status.localOnly.push(join(localPath, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
*/
|
||||
private async listLocalFiles(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath);
|
||||
return files.filter(file => COMMON_CONFIG_FILES.includes(file));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance of ConfigSyncService
|
||||
*/
|
||||
let configSyncServiceInstance: ConfigSyncService | null = null;
|
||||
|
||||
export function getConfigSyncService(): ConfigSyncService {
|
||||
if (!configSyncServiceInstance) {
|
||||
configSyncServiceInstance = new ConfigSyncService();
|
||||
}
|
||||
return configSyncServiceInstance;
|
||||
}
|
||||
174
ccw/src/core/services/version-checker.ts
Normal file
174
ccw/src/core/services/version-checker.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Version Checker Service
|
||||
* Checks application version against GitHub latest release
|
||||
* Uses caching to avoid excessive API calls
|
||||
*/
|
||||
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
/**
|
||||
* Version check result
|
||||
*/
|
||||
export interface VersionCheckResult {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version cache entry
|
||||
*/
|
||||
interface CacheEntry {
|
||||
data: VersionCheckResult;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version Checker Service
|
||||
* Checks for updates by comparing local version with GitHub releases
|
||||
*/
|
||||
export class VersionChecker {
|
||||
private cache: CacheEntry | null = null;
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly GITHUB_OWNER = 'dyw0830';
|
||||
private readonly GITHUB_REPO = 'ccw';
|
||||
private readonly GITHUB_API_URL = `https://api.github.com/repos/dyw0830/ccw/releases/latest`;
|
||||
|
||||
/**
|
||||
* Check for updates
|
||||
* Returns cached result if within TTL
|
||||
*/
|
||||
async checkVersion(): Promise<VersionCheckResult> {
|
||||
// Check cache first
|
||||
if (this.cache && Date.now() - this.cache.timestamp < this.CACHE_TTL) {
|
||||
return this.cache.data;
|
||||
}
|
||||
|
||||
// Get versions
|
||||
const currentVersion = await this.getLocalVersion();
|
||||
const latestVersion = await this.getLatestVersionFromGitHub();
|
||||
|
||||
const result: VersionCheckResult = {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: this.compareVersions(currentVersion, latestVersion) < 0
|
||||
};
|
||||
|
||||
// Cache result
|
||||
this.cache = { data: result, timestamp: Date.now() };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local version from package.json
|
||||
* Searches in monorepo root and ccw package directories
|
||||
*/
|
||||
private async getLocalVersion(): Promise<string> {
|
||||
// Try to find package.json with actual CCW version
|
||||
const possiblePaths = [
|
||||
join(process.cwd(), 'package.json'), // Current directory
|
||||
join(process.cwd(), 'ccw', 'package.json'), // ccw subdirectory
|
||||
join(__dirname, '..', '..', '..', '..', 'package.json'), // From src/core/services -> monorepo root
|
||||
];
|
||||
|
||||
for (const pkgPath of possiblePaths) {
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const content = await readFile(pkgPath, 'utf-8');
|
||||
const pkg = JSON.parse(content);
|
||||
if (pkg.version && typeof pkg.version === 'string') {
|
||||
return pkg.version;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to a default version if no package.json found
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest version from GitHub Releases API
|
||||
* Returns cached data if available even if expired, on error
|
||||
*/
|
||||
private async getLatestVersionFromGitHub(): Promise<string> {
|
||||
try {
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await fetch(this.GITHUB_API_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'CCW-VersionChecker', // REQUIRED by GitHub API
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
const data = await response.json() as { tag_name?: string };
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid GitHub API response format');
|
||||
}
|
||||
|
||||
if (!data.tag_name || typeof data.tag_name !== 'string') {
|
||||
throw new Error('Invalid tag_name in GitHub response');
|
||||
}
|
||||
|
||||
// Extract version from tag_name (remove 'v' prefix if present)
|
||||
const tagName = data.tag_name;
|
||||
return tagName.startsWith('v') ? tagName.substring(1) : tagName;
|
||||
} catch (error) {
|
||||
// Handle abort (timeout)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('GitHub API request timeout (10s)');
|
||||
}
|
||||
|
||||
// Return cached data if available, even if expired
|
||||
if (this.cache) {
|
||||
console.warn(`[VersionChecker] Using cached version due to error: ${(error as Error).message}`);
|
||||
return this.cache.data.latestVersion;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
*/
|
||||
private compareVersions(v1: string, v2: string): number {
|
||||
// Parse semver versions (major.minor.patch)
|
||||
const parts1 = v1.split('.').map((p) => (parseInt(p, 10) || 0));
|
||||
const parts2 = v2.split('.').map((p) => (parseInt(p, 10) || 0));
|
||||
|
||||
// Ensure we have at least 3 parts for comparison
|
||||
while (parts1.length < 3) parts1.push(0);
|
||||
while (parts2.length < 3) parts2.push(0);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (parts1[i] < parts2[i]) return -1;
|
||||
if (parts1[i] > parts2[i]) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the version cache (useful for testing or manual refresh)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user