Skip to main content
Glama
manage.ts32.7 kB
/* eslint-disable no-console */ /* eslint-disable no-control-regex */ import fs from 'fs'; import path from 'path'; import https from 'https'; import readline from 'readline'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ═══════════════════════════════════════════════════════════════════════════════ // CONFIGURATION // ═══════════════════════════════════════════════════════════════════════════════ const CONFIG = { repoOwner: 'OGMatrix', repoName: 'mcmodding-mcp', // Go up two levels from dist/cli/ or src/cli/ to get to root, then into data dataDir: path.join(__dirname, '..', '..', 'data'), userAgent: 'mcmodding-mcp-installer', }; interface OptionalDb { id: string; name: string; fileName: string; manifestName: string; description: string; tagPrefix: string; icon: string; isRequired?: boolean; localVersion?: string | null; remoteInfo?: RemoteInfo | null; selected?: boolean; hasUpdate?: boolean; } interface RemoteInfo { version: string; releaseId: number; downloadUrl: string; manifestUrl: string | null; size: number; publishedAt: string; hash?: string; } interface GitHubAsset { name: string; browser_download_url: string; size: number; } interface GitHubRelease { id: number; tag_name: string; published_at: string; assets: GitHubAsset[]; } const AVAILABLE_DBS: OptionalDb[] = [ { id: 'mcmodding-docs', name: 'Documentation Database', fileName: 'mcmodding-docs.db', manifestName: 'db-manifest.json', description: 'Core Fabric & NeoForge documentation - installed by default', tagPrefix: 'v', icon: '📚', isRequired: true, }, { id: 'mod-examples', name: 'Mod Examples Database', fileName: 'mod-examples.db', manifestName: 'mod-examples-manifest.json', description: '1000+ high-quality modding examples for Fabric & NeoForge', tagPrefix: 'examples-v', icon: '🧩', }, ]; // ═══════════════════════════════════════════════════════════════════════════════ // ANSI COLORS & STYLES // ═══════════════════════════════════════════════════════════════════════════════ const isColorSupported = process.stdout.isTTY && !process.env.NO_COLOR; const c = { reset: isColorSupported ? '\x1b[0m' : '', bold: isColorSupported ? '\x1b[1m' : '', dim: isColorSupported ? '\x1b[2m' : '', italic: isColorSupported ? '\x1b[3m' : '', underline: isColorSupported ? '\x1b[4m' : '', black: isColorSupported ? '\x1b[30m' : '', red: isColorSupported ? '\x1b[31m' : '', green: isColorSupported ? '\x1b[32m' : '', yellow: isColorSupported ? '\x1b[33m' : '', blue: isColorSupported ? '\x1b[34m' : '', magenta: isColorSupported ? '\x1b[35m' : '', cyan: isColorSupported ? '\x1b[36m' : '', white: isColorSupported ? '\x1b[37m' : '', brightBlack: isColorSupported ? '\x1b[90m' : '', brightRed: isColorSupported ? '\x1b[91m' : '', brightGreen: isColorSupported ? '\x1b[92m' : '', brightYellow: isColorSupported ? '\x1b[93m' : '', brightBlue: isColorSupported ? '\x1b[94m' : '', brightMagenta: isColorSupported ? '\x1b[95m' : '', brightCyan: isColorSupported ? '\x1b[96m' : '', brightWhite: isColorSupported ? '\x1b[97m' : '', bgBlue: isColorSupported ? '\x1b[44m' : '', clearLine: isColorSupported ? '\x1b[2K' : '', cursorUp: isColorSupported ? '\x1b[1A' : '', cursorHide: isColorSupported ? '\x1b[?25l' : '', cursorShow: isColorSupported ? '\x1b[?25h' : '', }; const sym = { topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', horizontal: '═', vertical: '║', sTopLeft: '┌', sTopRight: '┐', sBottomLeft: '└', sBottomRight: '┘', sHorizontal: '─', sVertical: '│', barFull: '█', barThreeQuarter: '▓', barHalf: '▒', barQuarter: '░', barEmpty: '░', check: '✔', cross: '✖', warning: '⚠', info: 'ℹ', star: '★', sparkle: '✨', rocket: '🚀', package: '📦', database: '🗄️', download: '⬇', shield: '🛡️', clock: '⏱', lightning: '⚡', arrowRight: '▶', dot: '●', circle: '○', selected: '◉', unselected: '○', update: '↻', cube: '◆', pause: '⏸', play: '▶', stop: '⏹', }; // ANSI escape sequences for cursor control const cursor = { save: isColorSupported ? '\x1b[s' : '', restore: isColorSupported ? '\x1b[u' : '', toColumn: (col: number) => (isColorSupported ? `\x1b[${col}G` : ''), up: (n: number) => (isColorSupported ? `\x1b[${n}A` : ''), down: (n: number) => (isColorSupported ? `\x1b[${n}B` : ''), }; // ═══════════════════════════════════════════════════════════════════════════════ // UTILITY FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════════ function getTerminalWidth() { return process.stdout.columns || 80; } function centerText(text: string, width: number) { const cleanText = text.replace(/\x1b\[[0-9;]*m/g, ''); const totalPadding = Math.max(0, width - cleanText.length); const leftPadding = Math.floor(totalPadding / 2); const rightPadding = totalPadding - leftPadding; return ' '.repeat(leftPadding) + text + ' '.repeat(rightPadding); } function formatBytes(bytes: number) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function formatSpeed(bytesPerSecond: number) { return formatBytes(bytesPerSecond) + '/s'; } function formatTime(seconds: number): string { if (!isFinite(seconds) || seconds <= 0) return '--:--'; if (seconds < 60) return `${Math.round(seconds)}s`; const mins = Math.floor(seconds / 60); const secs = Math.round(seconds % 60); return `${mins}m ${secs.toString().padStart(2, '0')}s`; } function padLine(text: string, width: number): string { const cleanText = text.replace(/\x1b\[[0-9;]*m/g, ''); const padding = Math.max(0, width - cleanText.length); return text + ' '.repeat(padding); } // ═══════════════════════════════════════════════════════════════════════════════ // API & NETWORK // ═══════════════════════════════════════════════════════════════════════════════ async function fetchJson(url: string): Promise<unknown> { return new Promise((resolve, reject) => { const options = { headers: { 'User-Agent': CONFIG.userAgent }, }; https .get(url, options, (res) => { if (res.statusCode !== 200) { res.resume(); return reject(new Error(`Request failed with status code ${res.statusCode}`)); } let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e instanceof Error ? e : new Error(String(e))); } }); }) .on('error', (err) => reject(err instanceof Error ? err : new Error(String(err)))); }); } async function downloadFile( url: string, destPath: string, onProgress?: (current: number, total: number) => void ): Promise<void> { return new Promise((resolve, reject) => { const file = fs.createWriteStream(destPath); const options = { headers: { 'User-Agent': CONFIG.userAgent }, }; https .get(url, options, (res) => { if (res.statusCode === 302 || res.statusCode === 301) { if (!res.headers.location) { return reject(new Error('Redirect location missing')); } file.close(); downloadFile(res.headers.location, destPath, onProgress).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { res.resume(); return reject(new Error(`Download failed with status code ${res.statusCode}`)); } const totalSize = parseInt(res.headers['content-length'] || '0', 10); let downloadedSize = 0; res.on('data', (chunk: Buffer) => { downloadedSize += chunk.length; file.write(chunk); if (onProgress) { onProgress(downloadedSize, totalSize); } }); res.on('end', () => { file.end(); resolve(); }); res.on('error', (err) => { fs.unlink(destPath, () => {}); reject(err); }); }) .on('error', (err) => { fs.unlink(destPath, () => {}); reject(err); }); }); } interface DownloadController { promise: Promise<void>; abort: () => void; } function downloadFileInteractive( url: string, destPath: string, progress: ProgressDisplay ): DownloadController { let abortController: { abort: () => void } | null = null; let pauseResolve: (() => void) | null = null; let keyHandler: ((key: Buffer) => void) | null = null; let currentRes: import('http').IncomingMessage | null = null; const cleanup = () => { if (keyHandler) { process.stdin.removeListener('data', keyHandler); process.stdin.setRawMode(false); process.stdin.pause(); } }; const promise = new Promise<void>((resolve, reject) => { const file = fs.createWriteStream(destPath); const options = { headers: { 'User-Agent': CONFIG.userAgent }, }; // Set up keyboard input process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); keyHandler = (key: Buffer) => { const keyStr = key.toString().toLowerCase(); if (keyStr === 'c' || keyStr === '\u0003') { // Cancel - mark as cancelled but don't reject yet progress.cancel(); if (currentRes) { currentRes.destroy(); } cleanup(); fs.unlink(destPath, () => {}); reject(new Error('Download cancelled by user')); } else if (keyStr === 'p' || keyStr === ' ') { // Toggle Pause/Resume progress.togglePause(); if (progress.paused) { if (currentRes) { currentRes.pause(); } } else { if (currentRes) { currentRes.resume(); } if (pauseResolve) { pauseResolve(); pauseResolve = null; } } // Force immediate redraw to show state change progress.forceRedraw(); } else if (keyStr === 'i') { // Toggle info progress.toggleDetail(); } }; process.stdin.on('data', keyHandler); const makeRequest = (requestUrl: string) => { const req = https.get(requestUrl, options, (res) => { currentRes = res; if (res.statusCode === 302 || res.statusCode === 301) { if (!res.headers.location) { cleanup(); return reject(new Error('Redirect location missing')); } makeRequest(res.headers.location); return; } if (res.statusCode !== 200) { res.resume(); cleanup(); return reject(new Error(`Download failed with status code ${res.statusCode}`)); } const totalSize = parseInt(res.headers['content-length'] || '0', 10); let downloadedSize = 0; res.on('data', (chunk: Buffer) => { if (progress.cancelled) return; downloadedSize += chunk.length; file.write(chunk); progress.update(downloadedSize, totalSize); }); res.on('end', () => { file.end(); cleanup(); if (!progress.cancelled) { resolve(); } }); res.on('error', (err) => { cleanup(); fs.unlink(destPath, () => {}); reject(err); }); }); req.on('error', (err) => { cleanup(); fs.unlink(destPath, () => {}); reject(err); }); abortController = { abort: () => { req.destroy(); }, }; }; makeRequest(url); }); return { promise, abort: () => { if (abortController) { abortController.abort(); } cleanup(); }, }; } // ═══════════════════════════════════════════════════════════════════════════════ // UI COMPONENTS // ═══════════════════════════════════════════════════════════════════════════════ function printHeader() { console.clear(); const width = Math.min(getTerminalWidth(), 80); const innerWidth = width - 4; console.log( c.brightCyan + sym.topLeft + sym.horizontal.repeat(width - 2) + sym.topRight + c.reset ); console.log( c.brightCyan + sym.vertical + c.reset + centerText(`${c.brightWhite}${c.bold}MCModding-MCP Database Manager${c.reset}`, innerWidth) + c.brightCyan + sym.vertical + c.reset ); console.log( c.brightCyan + sym.vertical + c.reset + centerText( `${c.dim}Install, update, and manage your documentation databases${c.reset}`, innerWidth ) + c.brightCyan + sym.vertical + c.reset ); console.log( c.brightCyan + sym.bottomLeft + sym.horizontal.repeat(width - 2) + sym.bottomRight + c.reset ); console.log(); } class ProgressDisplay { private lines = 0; private lastUpdate = 0; private label: string; private startTime: number; private speeds: number[] = []; private detailedView = false; private isPaused = false; private isCancelled = false; private lastDownloaded = 0; private lastTotal = 0; private speedCalcDownloaded = 0; private lastSpeedTime = 0; private initialized = false; constructor(label = 'Downloading') { this.label = label; this.startTime = Date.now(); this.lastSpeedTime = this.startTime; } get paused() { return this.isPaused; } get cancelled() { return this.isCancelled; } togglePause() { this.isPaused = !this.isPaused; if (!this.isPaused) { // Reset speed calculation on resume this.lastSpeedTime = Date.now(); this.speedCalcDownloaded = this.lastDownloaded; } } cancel() { this.isCancelled = true; } toggleDetail() { this.detailedView = !this.detailedView; } forceRedraw() { this.lastUpdate = 0; // Trigger redraw with last known values if (this.lastTotal > 0) { this.update(this.lastDownloaded, this.lastTotal); } } private calculateSpeed(downloaded: number): number { const now = Date.now(); const timeDelta = (now - this.lastSpeedTime) / 1000; if (timeDelta >= 0.5) { const bytesDelta = downloaded - this.speedCalcDownloaded; const instantSpeed = bytesDelta / timeDelta; this.speeds.push(instantSpeed); if (this.speeds.length > 5) this.speeds.shift(); this.speedCalcDownloaded = downloaded; this.lastSpeedTime = now; } if (this.speeds.length === 0) return 0; return this.speeds.reduce((a, b) => a + b, 0) / this.speeds.length; } update(downloaded: number, total: number) { if (this.isCancelled) return; // Store values for forceRedraw this.lastDownloaded = downloaded; this.lastTotal = total; const now = Date.now(); // Throttle updates but allow first update immediately if (this.initialized && now - this.lastUpdate < 80) return; this.lastUpdate = now; const width = Math.min(getTerminalWidth(), 80); const barWidth = Math.max(10, width - 35); const percentage = total > 0 ? Math.min(100, (downloaded / total) * 100) : 0; const filledWidth = Math.round((percentage / 100) * barWidth); const emptyWidth = barWidth - filledWidth; const filledBar = c.brightGreen + sym.barFull.repeat(filledWidth) + c.reset; const emptyBar = c.brightBlack + sym.barEmpty.repeat(emptyWidth) + c.reset; const speed = this.isPaused ? 0 : this.calculateSpeed(downloaded); const eta = speed > 0 ? (total - downloaded) / speed : 0; const elapsed = (now - this.startTime) / 1000; const sizeStr = `${c.white}${formatBytes(downloaded)}${c.brightBlack}/${c.cyan}${formatBytes(total)}${c.reset}`; const speedStr = this.isPaused ? `${c.yellow}${sym.pause} Paused${c.reset}` : `${c.brightGreen}${formatSpeed(speed)}${c.reset}`; const etaStr = `${c.brightMagenta}${formatTime(eta)}${c.reset}`; // Status icon and text with clear colors const statusIcon = this.isPaused ? `${c.yellow}${sym.pause}` : `${c.brightCyan}${sym.download}`; const statusText = this.isPaused ? `${c.yellow}Paused` : `${c.brightCyan}${this.label}`; const lines: string[] = []; // Line 1: Status lines.push(padLine(`${statusIcon} ${statusText}...${c.reset}`, width)); // Line 2: Progress bar with percentage color based on progress const pctColor = percentage >= 100 ? c.brightGreen : percentage >= 50 ? c.brightCyan : c.brightYellow; const progressLine = `${filledBar}${emptyBar} ${pctColor}${percentage.toFixed(1)}%${c.reset}`; lines.push(padLine(progressLine, width)); // Line 3: Stats - using distinct colors const statsLine = `${sizeStr} ${c.brightBlack}${sym.lightning}${c.reset} ${speedStr} ${c.brightBlack}${sym.clock}${c.reset} ${etaStr}`; lines.push(padLine(statsLine, width)); // Line 4: Detailed view (optional) if (this.detailedView) { const avgSpeed = elapsed > 0 ? formatSpeed(downloaded / elapsed) : '0 B/s'; const detailLine = `${c.brightBlack}Elapsed: ${c.brightMagenta}${formatTime(elapsed)}${c.brightBlack} Average: ${c.brightGreen}${avgSpeed}${c.reset}`; lines.push(padLine(detailLine, width)); } // Line 5: Keybinds help - clear action colors const pauseKey = this.isPaused ? c.brightGreen : c.yellow; const pauseAction = this.isPaused ? 'Resume' : 'Pause'; const pauseHint = `${pauseKey}[P]${c.reset} ${c.white}${pauseAction}${c.reset}`; const cancelHint = `${c.brightRed}[C]${c.reset} ${c.white}Cancel${c.reset}`; const infoState = this.detailedView ? `${c.brightGreen}on${c.reset}` : `${c.brightBlack}off${c.reset}`; const infoHint = `${c.cyan}[I]${c.reset} ${c.white}Info${c.reset} ${infoState}`; lines.push(padLine(`${pauseHint} ${cancelHint} ${infoHint}`, width)); // Build output buffer - move cursor up if we've already drawn, then overwrite let output = ''; if (this.initialized) { // Move up by the previous line count output += cursor.up(this.lines); } output += c.cursorHide; output += lines.map((line) => cursor.toColumn(1) + line).join('\n') + '\n'; // If we have fewer lines now, clear the stale lines at the bottom if (this.initialized && lines.length < this.lines) { const extraLines = this.lines - lines.length; for (let i = 0; i < extraLines; i++) { output += cursor.toColumn(1) + ' '.repeat(width) + '\n'; } // Move cursor back up to end of our new content output += cursor.up(extraLines); } process.stdout.write(output); this.lines = lines.length; this.initialized = true; } finish(success = true, message = '') { // Move up and clear our lines if (this.initialized && this.lines > 0) { process.stdout.write(cursor.up(this.lines)); const width = Math.min(getTerminalWidth(), 80); for (let i = 0; i < this.lines; i++) { process.stdout.write(cursor.toColumn(1) + ' '.repeat(width) + '\n'); } process.stdout.write(cursor.up(this.lines)); } const icon = success ? c.brightGreen + sym.check : c.brightRed + sym.cross; const color = success ? c.brightGreen : c.brightRed; process.stdout.write(`${icon}${c.reset} ${color}${message}${c.reset}\n` + c.cursorShow); this.lines = 0; this.initialized = false; } } // ═══════════════════════════════════════════════════════════════════════════════ // LOGIC // ═══════════════════════════════════════════════════════════════════════════════ function getLocalVersion(dbConfig: OptionalDb): string | null { const manifestPath = path.join(CONFIG.dataDir, dbConfig.manifestName); if (fs.existsSync(manifestPath)) { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { version: string }; return manifest.version; } catch { return null; } } return null; } async function getRemoteVersion(dbConfig: OptionalDb): Promise<RemoteInfo | null> { try { // Fetch releases from GitHub const releases = (await fetchJson( `https://api.github.com/repos/${CONFIG.repoOwner}/${CONFIG.repoName}/releases` )) as GitHubRelease[]; // Find the latest release matching the tag prefix const release = releases.find((r) => r.tag_name.startsWith(dbConfig.tagPrefix)); if (!release) return null; // Extract version from tag (e.g., examples-v0.1.0 -> 0.1.0) const version = release.tag_name.replace(dbConfig.tagPrefix, ''); // Find assets const dbAsset = release.assets.find((a) => a.name === dbConfig.fileName); const manifestAsset = release.assets.find((a) => a.name === dbConfig.manifestName); if (!dbAsset) return null; return { version, releaseId: release.id, downloadUrl: dbAsset.browser_download_url, manifestUrl: manifestAsset ? manifestAsset.browser_download_url : null, size: dbAsset.size, publishedAt: release.published_at, }; } catch { return null; } } async function promptSelection(options: OptionalDb[]): Promise<OptionalDb[]> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); let selectedIndex = 0; const render = () => { printHeader(); console.log(`${c.brightWhite}Select databases to install or update:${c.reset}\n`); options.forEach((opt, idx) => { const isSelected = idx === selectedIndex; const prefix = isSelected ? `${c.brightCyan}${sym.arrowRight} ` : ' '; const checkbox = opt.selected ? `${c.brightGreen}${sym.selected}${c.reset}` : `${c.brightBlack}${sym.unselected}${c.reset}`; const style = isSelected ? c.brightWhite + c.bold : c.white; const requiredBadge = opt.isRequired ? ` ${c.brightBlack}[core]${c.reset}` : ''; console.log(`${prefix}${checkbox} ${style}${opt.icon} ${opt.name}${c.reset}${requiredBadge}`); // Status line with clear color hierarchy let status = ''; if (opt.localVersion) { status += `${c.green}${sym.check} v${opt.localVersion}${c.reset}`; } else { status += `${c.yellow}${sym.warning} Not installed${c.reset}`; } if (opt.remoteInfo) { if (opt.hasUpdate) { status += ` ${c.brightBlack}→${c.reset} ${c.brightYellow}v${opt.remoteInfo.version}${c.reset}`; } else if (!opt.localVersion) { status += ` ${c.brightBlack}→${c.reset} ${c.cyan}v${opt.remoteInfo.version}${c.reset}`; } else { status += ` ${c.brightBlack}(up to date)${c.reset}`; } status += ` ${c.brightBlack}[${formatBytes(opt.remoteInfo.size)}]${c.reset}`; } else { status += ` ${c.red}(offline)${c.reset}`; } console.log(` ${status}`); console.log(` ${c.brightBlack}${opt.description}${c.reset}\n`); }); console.log( `${c.brightBlack}↑/↓${c.reset} Navigate ${c.brightBlack}Space${c.reset} Toggle ${c.brightBlack}Enter${c.reset} Confirm ${c.brightBlack}Ctrl+C${c.reset} Exit` ); }; return new Promise((resolve) => { // Initial state: select first item if available if (options.length > 0 && options[0]) { options[0].selected = true; } process.stdin.setRawMode(true); process.stdin.resume(); render(); process.stdin.on('data', (key) => { const keyStr = key.toString(); if (keyStr === '\u0003') { // Ctrl+C process.exit(0); } else if (keyStr === '\u001b[A') { // Up selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1; render(); } else if (keyStr === '\u001b[B') { // Down selectedIndex = selectedIndex < options.length - 1 ? selectedIndex + 1 : 0; render(); } else if (keyStr === ' ') { // Space const selectedOption = options[selectedIndex]; if (selectedOption) { selectedOption.selected = !selectedOption.selected; render(); } } else if (keyStr === '\r') { // Enter process.stdin.setRawMode(false); process.stdin.pause(); rl.close(); resolve(options.filter((o) => o.selected)); } }); }); } function compareVersions(v1: string, v2: string): number { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return 1; if (p1 < p2) return -1; } return 0; } export async function runInstaller() { printHeader(); console.log(`${c.cyan}${sym.info} Checking for available databases...${c.reset}\n`); // Gather info for all databases const choices: OptionalDb[] = []; for (const db of AVAILABLE_DBS) { console.log(`${c.dim} Checking ${db.name}...${c.reset}`); const localVersion = getLocalVersion(db); const remoteInfo = await getRemoteVersion(db); // Determine if update is available let hasUpdate = false; if (localVersion && remoteInfo) { hasUpdate = compareVersions(remoteInfo.version, localVersion) > 0; } choices.push({ ...db, localVersion, remoteInfo, selected: false, hasUpdate }); } // Clear the checking messages process.stdout.write((c.cursorUp + c.clearLine).repeat(choices.length + 1)); if (choices.length === 0) { console.log(`${c.yellow}No databases found in configuration.${c.reset}`); return; } // Show summary before selection const needsUpdate = choices.filter((c) => c.hasUpdate); const notInstalled = choices.filter((c) => !c.localVersion); if (needsUpdate.length > 0) { console.log( `${c.brightYellow}${sym.update}${c.reset} ${c.white}${needsUpdate.length} update(s) available${c.reset}` ); } if (notInstalled.length > 0) { console.log( `${c.cyan}${sym.download}${c.reset} ${c.white}${notInstalled.length} database(s) not installed${c.reset}` ); } if (needsUpdate.length === 0 && notInstalled.length === 0) { console.log( `${c.green}${sym.check}${c.reset} ${c.white}All databases are up to date!${c.reset}` ); } console.log(); // User selection const selected = await promptSelection(choices); if (selected.length === 0) { console.log(`\n${c.yellow}No databases selected. Exiting.${c.reset}`); process.stdout.write(c.cursorShow); return; } console.log(`\n${c.brightWhite}Starting installation/update...${c.reset}\n`); // Process installation let successCount = 0; let failCount = 0; for (const item of selected) { if (!item.remoteInfo) { console.log( `${c.red}${sym.cross} Skipping ${item.name}: Remote version unavailable.${c.reset}` ); failCount++; continue; } const destDbPath = path.join(CONFIG.dataDir, item.fileName); const destManifestPath = path.join(CONFIG.dataDir, item.manifestName); const tempDbPath = destDbPath + '.tmp'; const action = item.localVersion ? 'Updating' : 'Installing'; const versionInfo = item.localVersion ? `${c.brightBlack}v${item.localVersion}${c.reset} ${c.white}→${c.reset} ${c.brightCyan}v${item.remoteInfo.version}${c.reset}` : `${c.brightCyan}v${item.remoteInfo.version}${c.reset}`; console.log( `${c.cyan}${sym.package}${c.reset} ${c.white}${action}${c.reset} ${item.icon} ${c.brightWhite}${item.name}${c.reset} ${c.brightBlack}(${c.reset}${versionInfo}${c.brightBlack})${c.reset}` ); // Ensure data dir exists if (!fs.existsSync(CONFIG.dataDir)) { fs.mkdirSync(CONFIG.dataDir, { recursive: true }); } try { // Download DB to temp file first const progress = new ProgressDisplay(`Downloading ${item.fileName}`); console.log(); // Space for progress bar const download = downloadFileInteractive(item.remoteInfo.downloadUrl, tempDbPath, progress); try { await download.promise; progress.finish(true, 'Download complete!'); } catch (err) { if (err instanceof Error && err.message === 'Download cancelled by user') { progress.finish(false, 'Download cancelled'); // Return to main menu console.log(`\n${c.brightBlack}Returning to menu...${c.reset}\n`); await new Promise((r) => setTimeout(r, 800)); return runInstaller(); } throw err; } // Move temp file to final location (atomic on most systems) if (fs.existsSync(destDbPath)) { fs.unlinkSync(destDbPath); } fs.renameSync(tempDbPath, destDbPath); // Download Manifest (if available) if (item.remoteInfo.manifestUrl) { console.log(`${c.dim} Fetching manifest...${c.reset}`); await downloadFile(item.remoteInfo.manifestUrl, destManifestPath); } else { // Create minimal manifest if missing const minimalManifest = { version: item.remoteInfo.version, updatedAt: new Date().toISOString(), source: 'manual-install', }; fs.writeFileSync(destManifestPath, JSON.stringify(minimalManifest, null, 2)); } console.log( `${c.brightGreen}${sym.sparkle} Successfully ${item.localVersion ? 'updated' : 'installed'} ${item.name}!${c.reset}\n` ); successCount++; } catch (err) { // Cleanup temp file if it exists if (fs.existsSync(tempDbPath)) { fs.unlinkSync(tempDbPath); } const message = err instanceof Error ? err.message : String(err); console.error( `\n${c.red}${sym.cross} Failed to ${item.localVersion ? 'update' : 'install'} ${item.name}: ${message}${c.reset}\n` ); failCount++; } } // Final summary console.log(); if (successCount > 0 && failCount === 0) { console.log( `${c.green}${sym.sparkle}${c.reset} ${c.white}All ${successCount} operation(s) completed successfully!${c.reset}` ); } else if (successCount > 0 && failCount > 0) { console.log( `${c.yellow}${sym.warning}${c.reset} ${c.white}Completed: ${c.green}${successCount} succeeded${c.white}, ${c.red}${failCount} failed${c.reset}` ); } else if (failCount > 0) { console.log(`${c.red}${sym.cross}${c.reset} ${c.white}All operations failed${c.reset}`); } process.stdout.write(c.cursorShow); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/OGMatrix/mcmodding-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server