const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs').promises; class PackageRegistry { constructor(dbPath = null) { // If no dbPath provided, we'll set it during init based on current data directory this.dbPath = dbPath; this.db = null; this.lastVerificationTime = 0; this.verificationInterval = 5 * 60 * 1000; // 5 minutes } async init() { // Set database path based on current data directory if not already set if (!this.dbPath) { const paths = require('./paths'); await paths.initialize(); this.dbPath = path.join(paths.minetestDir, 'luhost_packages.db'); } // Ensure data directory exists const dir = path.dirname(this.dbPath); await fs.mkdir(dir, { recursive: true }); return new Promise((resolve, reject) => { this.db = new sqlite3.Database(this.dbPath, (err) => { if (err) { reject(err); } else { this.createTables().then(resolve).catch(reject); } }); }); } async reinitialize() { // Close existing database connection if (this.db) { await new Promise((resolve) => { this.db.close((err) => { if (err) console.error('Error closing database:', err); resolve(); }); }); } // Clear the path so it gets recalculated this.dbPath = null; // Reinitialize with new path return this.init(); } async createTables() { const sql = ` CREATE TABLE IF NOT EXISTS installed_packages ( id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, name TEXT NOT NULL, version TEXT, release_id TEXT, install_location TEXT NOT NULL, -- 'global' or 'world:worldname' install_path TEXT NOT NULL, installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, contentdb_url TEXT, package_type TEXT, -- 'mod', 'game', 'txp' title TEXT, short_description TEXT, dependencies TEXT, -- JSON string of dependencies UNIQUE(author, name, install_location) ); CREATE INDEX IF NOT EXISTS idx_package_location ON installed_packages(install_location); CREATE INDEX IF NOT EXISTS idx_package_name ON installed_packages(author, name); `; return new Promise((resolve, reject) => { this.db.exec(sql, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } async recordInstallation(packageInfo) { const { author, name, version, releaseId, installLocation, installPath, contentdbUrl, packageType, title, shortDescription, dependencies = [] } = packageInfo; const sql = ` INSERT OR REPLACE INTO installed_packages (author, name, version, release_id, install_location, install_path, contentdb_url, package_type, title, short_description, dependencies, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `; const dependenciesJson = JSON.stringify(dependencies); return new Promise((resolve, reject) => { this.db.run(sql, [ author, name, version, releaseId, installLocation, installPath, contentdbUrl, packageType, title, shortDescription, dependenciesJson ], function(err) { if (err) { reject(err); } else { resolve({ id: this.lastID, changes: this.changes }); } }); }); } async getInstalledPackages(location = null) { let sql = 'SELECT * FROM installed_packages'; const params = []; if (location) { if (location === 'global') { sql += ' WHERE install_location = ?'; params.push('global'); } else if (location.startsWith('world:')) { sql += ' WHERE install_location = ?'; params.push(location); } } sql += ' ORDER BY installed_at DESC'; return new Promise((resolve, reject) => { this.db.all(sql, params, (err, rows) => { if (err) { reject(err); } else { // Parse dependencies JSON for each row const packages = rows.map(row => ({ ...row, dependencies: row.dependencies ? JSON.parse(row.dependencies) : [] })); resolve(packages); } }); }); } async getInstalledPackage(author, name, location) { const sql = ` SELECT * FROM installed_packages WHERE author = ? AND name = ? AND install_location = ? `; return new Promise((resolve, reject) => { this.db.get(sql, [author, name, location], (err, row) => { if (err) { reject(err); } else if (row) { row.dependencies = row.dependencies ? JSON.parse(row.dependencies) : []; resolve(row); } else { resolve(null); } }); }); } async isPackageInstalled(author, name, location) { const packageInfo = await this.getInstalledPackage(author, name, location); return packageInfo !== null; } async removePackage(author, name, location) { const sql = ` DELETE FROM installed_packages WHERE author = ? AND name = ? AND install_location = ? `; return new Promise((resolve, reject) => { this.db.run(sql, [author, name, location], function(err) { if (err) { reject(err); } else { resolve({ changes: this.changes }); } }); }); } async getPackagesByWorld(worldName) { return this.getInstalledPackages(`world:${worldName}`); } async getGlobalPackages() { return this.getInstalledPackages('global'); } async getAllInstallations() { return this.getInstalledPackages(); } async updatePackageInfo(author, name, location, updates) { const setClause = []; const params = []; for (const [key, value] of Object.entries(updates)) { if (key === 'dependencies' && Array.isArray(value)) { setClause.push(`${key} = ?`); params.push(JSON.stringify(value)); } else { setClause.push(`${key} = ?`); params.push(value); } } setClause.push('updated_at = CURRENT_TIMESTAMP'); const sql = ` UPDATE installed_packages SET ${setClause.join(', ')} WHERE author = ? AND name = ? AND install_location = ? `; params.push(author, name, location); return new Promise((resolve, reject) => { this.db.run(sql, params, function(err) { if (err) { reject(err); } else { resolve({ changes: this.changes }); } }); }); } async getStatistics() { const sql = ` SELECT COUNT(*) as total_packages, COUNT(CASE WHEN install_location = 'global' THEN 1 END) as global_packages, COUNT(CASE WHEN install_location LIKE 'world:%' THEN 1 END) as world_packages, COUNT(DISTINCT CASE WHEN install_location LIKE 'world:%' THEN install_location END) as worlds_with_packages FROM installed_packages `; return new Promise((resolve, reject) => { this.db.get(sql, [], (err, row) => { if (err) { reject(err); } else { resolve(row); } }); }); } async close() { if (this.db) { return new Promise((resolve) => { this.db.close((err) => { if (err) { console.error('Error closing database:', err); } resolve(); }); }); } } async verifyAndCleanRegistry() { if (!this.db) { await this.init(); } const fs = require('fs').promises; return new Promise((resolve, reject) => { this.db.all('SELECT * FROM installed_packages', async (err, rows) => { if (err) { reject(err); return; } const toRemove = []; for (const row of rows) { try { // Check if the package still exists at the recorded path await fs.access(row.install_path); // Additional verification for games/mods - check for key files if (row.package_type === 'game') { // Check for game.conf const gameConfPath = require('path').join(row.install_path, 'game.conf'); await fs.access(gameConfPath); } else if (row.package_type === 'mod') { // Check for mod.conf or init.lua const modConfPath = require('path').join(row.install_path, 'mod.conf'); const initLuaPath = require('path').join(row.install_path, 'init.lua'); try { await fs.access(modConfPath); } catch { await fs.access(initLuaPath); } } } catch (accessError) { // Package directory or key files don't exist - mark for removal console.log(`Package registry cleanup: Removing stale entry for ${row.author}/${row.name} (path not found: ${row.install_path})`); toRemove.push(row.id); } } // Remove stale entries if (toRemove.length > 0) { const placeholders = toRemove.map(() => '?').join(','); this.db.run(`DELETE FROM installed_packages WHERE id IN (${placeholders})`, toRemove, (deleteErr) => { if (deleteErr) { console.error('Error cleaning up registry:', deleteErr); reject(deleteErr); } else { console.log(`Package registry cleanup: Removed ${toRemove.length} stale entries`); resolve(toRemove.length); } }); } else { resolve(0); } }); }); } async verifyIfNeeded() { const now = Date.now(); if (now - this.lastVerificationTime > this.verificationInterval) { try { const cleaned = await this.verifyAndCleanRegistry(); this.lastVerificationTime = now; return cleaned; } catch (error) { console.warn('Periodic registry verification failed:', error); return 0; } } return 0; } } module.exports = PackageRegistry;