## Major Features Added
### Configuration-Based Mod Management
- Implement proper Luanti mod system using load_mod_* entries in world.mt
- Add mod enable/disable via configuration instead of file copying
- Support both global mods (config-enabled) and world mods (physically installed)
- Clear UI distinction with badges: "Global (Enabled)", "World Copy", "Missing"
- Automatic registry verification to sync database with filesystem state
### Game ID Alias System
- Fix minetest_game/minetest technical debt with proper alias mapping
- Map minetest_game → minetest for world.mt files (matches Luanti internal behavior)
- Reference: c9d4c33174/src/content/subgames.cpp (L21)
### Navigation Improvements
- Fix navigation menu spacing and text overflow issues
- Change "Configuration" to "Config" for better fit
- Implement responsive font sizing with clamp() for better scaling
- Even distribution of nav buttons across full width
### Package Registry Enhancements
- Add verifyAndCleanRegistry() to automatically remove stale package entries
- Periodic verification (every 5 minutes) to keep registry in sync with filesystem
- Fix "already installed" errors for manually deleted packages
- Integration across dashboard, ContentDB, and installation workflows
## Technical Improvements
### Mod System Architecture
- Enhanced ConfigParser to handle load_mod_* entries in world.mt files
- Support for both configuration-based and file-based mod installations
- Proper mod type detection and management workflows
- Updated world details to show comprehensive mod information
### UI/UX Enhancements
- Responsive navigation with proper text scaling
- Improved mod management interface with clear action buttons
- Better visual hierarchy and status indicators
- Enhanced error handling and user feedback
### Code Quality
- Clean up gitignore to properly exclude runtime files
- Add package-lock.json for consistent dependency management
- Remove excess runtime database and log files
- Add .claude/ directory to gitignore
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
361 lines
10 KiB
JavaScript
361 lines
10 KiB
JavaScript
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; |