## 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>
391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
const paths = require('../utils/paths');
|
|
const ConfigParser = require('../utils/config-parser');
|
|
|
|
const router = express.Router();
|
|
|
|
// Mods listing page
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
paths.ensureDirectories();
|
|
|
|
let globalMods = [];
|
|
let worlds = [];
|
|
|
|
// Get global mods
|
|
try {
|
|
const modDirs = await fs.readdir(paths.modsDir);
|
|
|
|
for (const modDir of modDirs) {
|
|
try {
|
|
const modPath = paths.getModPath(modDir);
|
|
const configPath = paths.getModConfigPath(modDir);
|
|
|
|
const stats = await fs.stat(modPath);
|
|
if (!stats.isDirectory()) continue;
|
|
|
|
const config = await ConfigParser.parseModConfig(configPath);
|
|
|
|
globalMods.push({
|
|
name: modDir,
|
|
title: config.title || modDir,
|
|
description: config.description || '',
|
|
author: config.author || '',
|
|
depends: config.depends || [],
|
|
optional_depends: config.optional_depends || [],
|
|
min_minetest_version: config.min_minetest_version || '',
|
|
max_minetest_version: config.max_minetest_version || '',
|
|
location: 'global',
|
|
path: modPath,
|
|
lastModified: stats.mtime
|
|
});
|
|
} catch (modError) {
|
|
console.error(`Error reading mod ${modDir}:`, modError);
|
|
}
|
|
}
|
|
} catch (dirError) {}
|
|
|
|
// Get worlds for dropdown
|
|
try {
|
|
const worldDirs = await fs.readdir(paths.worldsDir);
|
|
for (const worldDir of worldDirs) {
|
|
try {
|
|
const worldPath = paths.getWorldPath(worldDir);
|
|
const configPath = paths.getWorldConfigPath(worldDir);
|
|
const stats = await fs.stat(worldPath);
|
|
if (stats.isDirectory()) {
|
|
const config = await ConfigParser.parseWorldConfig(configPath);
|
|
worlds.push({
|
|
name: worldDir,
|
|
displayName: config.server_name || worldDir
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
const selectedWorld = req.query.world;
|
|
let worldMods = [];
|
|
|
|
if (selectedWorld && paths.isValidWorldName(selectedWorld)) {
|
|
try {
|
|
const worldModsPath = paths.getWorldModsPath(selectedWorld);
|
|
const modDirs = await fs.readdir(worldModsPath);
|
|
|
|
for (const modDir of modDirs) {
|
|
try {
|
|
const modPath = path.join(worldModsPath, modDir);
|
|
const configPath = path.join(modPath, 'mod.conf');
|
|
|
|
const stats = await fs.stat(modPath);
|
|
if (!stats.isDirectory()) continue;
|
|
|
|
const config = await ConfigParser.parseModConfig(configPath);
|
|
|
|
worldMods.push({
|
|
name: modDir,
|
|
title: config.title || modDir,
|
|
description: config.description || '',
|
|
author: config.author || '',
|
|
depends: config.depends || [],
|
|
optional_depends: config.optional_depends || [],
|
|
location: 'world',
|
|
enabled: true,
|
|
path: modPath,
|
|
lastModified: stats.mtime
|
|
});
|
|
} catch (modError) {
|
|
console.error(`Error reading world mod ${modDir}:`, modError);
|
|
}
|
|
}
|
|
} catch (dirError) {}
|
|
}
|
|
|
|
res.render('mods/index', {
|
|
title: 'Mod Management',
|
|
globalMods: globalMods,
|
|
worldMods: worldMods,
|
|
worlds: worlds,
|
|
selectedWorld: selectedWorld,
|
|
currentPage: 'mods'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting mods:', error);
|
|
res.status(500).render('error', {
|
|
error: 'Failed to load mods',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Enable mod for world (configuration-based)
|
|
router.post('/enable/:worldName/:modName', async (req, res) => {
|
|
try {
|
|
const { worldName, modName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
|
return res.status(400).json({ error: 'Invalid world or mod name' });
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(worldName);
|
|
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
|
const globalModPath = paths.getModPath(modName);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'World not found' });
|
|
}
|
|
|
|
try {
|
|
await fs.access(globalModPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Mod not found' });
|
|
}
|
|
|
|
// Read current world configuration
|
|
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
|
|
|
// Enable the mod
|
|
if (!config.enabled_mods) {
|
|
config.enabled_mods = {};
|
|
}
|
|
config.enabled_mods[modName] = true;
|
|
|
|
// Write updated configuration
|
|
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
|
|
|
res.redirect(`/mods?world=${worldName}&enabled=${modName}`);
|
|
} catch (error) {
|
|
console.error('Error enabling mod for world:', error);
|
|
res.status(500).json({ error: 'Failed to enable mod for world' });
|
|
}
|
|
});
|
|
|
|
// Copy mod to world (physical installation)
|
|
router.post('/copy/:worldName/:modName', async (req, res) => {
|
|
try {
|
|
const { worldName, modName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
|
return res.status(400).json({ error: 'Invalid world or mod name' });
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(worldName);
|
|
const globalModPath = paths.getModPath(modName);
|
|
const worldModsPath = paths.getWorldModsPath(worldName);
|
|
const targetModPath = path.join(worldModsPath, modName);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'World not found' });
|
|
}
|
|
|
|
try {
|
|
await fs.access(globalModPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Mod not found' });
|
|
}
|
|
|
|
try {
|
|
await fs.access(targetModPath);
|
|
return res.status(409).json({ error: 'Mod already copied to world' });
|
|
} catch {}
|
|
|
|
await fs.mkdir(worldModsPath, { recursive: true });
|
|
await fs.cp(globalModPath, targetModPath, { recursive: true });
|
|
|
|
res.redirect(`/mods?world=${worldName}&copied=${modName}`);
|
|
} catch (error) {
|
|
console.error('Error copying mod to world:', error);
|
|
res.status(500).json({ error: 'Failed to copy mod to world' });
|
|
}
|
|
});
|
|
|
|
// Disable mod from world (configuration-based)
|
|
router.post('/disable/:worldName/:modName', async (req, res) => {
|
|
try {
|
|
const { worldName, modName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
|
return res.status(400).json({ error: 'Invalid world or mod name' });
|
|
}
|
|
|
|
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
|
|
|
// Read current world configuration
|
|
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
|
|
|
// Disable the mod
|
|
if (config.enabled_mods && config.enabled_mods[modName]) {
|
|
config.enabled_mods[modName] = false;
|
|
}
|
|
|
|
// Write updated configuration
|
|
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
|
|
|
res.redirect(`/mods?world=${worldName}&disabled=${modName}`);
|
|
} catch (error) {
|
|
console.error('Error disabling mod for world:', error);
|
|
res.status(500).json({ error: 'Failed to disable mod for world' });
|
|
}
|
|
});
|
|
|
|
// Remove mod from world (delete physical copy)
|
|
router.post('/remove/:worldName/:modName', async (req, res) => {
|
|
try {
|
|
const { worldName, modName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
|
return res.status(400).json({ error: 'Invalid world or mod name' });
|
|
}
|
|
|
|
const worldModsPath = paths.getWorldModsPath(worldName);
|
|
const modPath = path.join(worldModsPath, modName);
|
|
|
|
try {
|
|
await fs.access(modPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Mod not found in world' });
|
|
}
|
|
|
|
await fs.rm(modPath, { recursive: true, force: true });
|
|
|
|
res.redirect(`/mods?world=${worldName}&removed=${modName}`);
|
|
} catch (error) {
|
|
console.error('Error removing mod from world:', error);
|
|
res.status(500).json({ error: 'Failed to remove mod from world' });
|
|
}
|
|
});
|
|
|
|
// Delete global mod
|
|
router.post('/delete/:modName', async (req, res) => {
|
|
try {
|
|
const { modName } = req.params;
|
|
|
|
if (!paths.isValidModName(modName)) {
|
|
return res.status(400).json({ error: 'Invalid mod name' });
|
|
}
|
|
|
|
const modPath = paths.getModPath(modName);
|
|
|
|
try {
|
|
await fs.access(modPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Mod not found' });
|
|
}
|
|
|
|
await fs.rm(modPath, { recursive: true, force: true });
|
|
|
|
res.redirect(`/mods?deleted=${modName}`);
|
|
} catch (error) {
|
|
console.error('Error deleting mod:', error);
|
|
res.status(500).json({ error: 'Failed to delete mod' });
|
|
}
|
|
});
|
|
|
|
// Mod details page
|
|
router.get('/:modName', async (req, res) => {
|
|
try {
|
|
const { modName } = req.params;
|
|
|
|
if (!paths.isValidModName(modName)) {
|
|
return res.status(400).render('error', {
|
|
error: 'Invalid mod name'
|
|
});
|
|
}
|
|
|
|
const modPath = paths.getModPath(modName);
|
|
const configPath = paths.getModConfigPath(modName);
|
|
|
|
try {
|
|
await fs.access(modPath);
|
|
} catch {
|
|
return res.status(404).render('error', {
|
|
error: 'Mod not found'
|
|
});
|
|
}
|
|
|
|
const config = await ConfigParser.parseModConfig(configPath);
|
|
const stats = await fs.stat(modPath);
|
|
|
|
// Get mod files info
|
|
let fileCount = 0;
|
|
let totalSize = 0;
|
|
|
|
async function countFiles(dirPath) {
|
|
try {
|
|
const items = await fs.readdir(dirPath);
|
|
for (const item of items) {
|
|
const itemPath = path.join(dirPath, item);
|
|
const itemStats = await fs.stat(itemPath);
|
|
if (itemStats.isDirectory()) {
|
|
await countFiles(itemPath);
|
|
} else {
|
|
fileCount++;
|
|
totalSize += itemStats.size;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
await countFiles(modPath);
|
|
|
|
// Get worlds where this mod is installed
|
|
const installedWorlds = [];
|
|
try {
|
|
const worldDirs = await fs.readdir(paths.worldsDir);
|
|
for (const worldDir of worldDirs) {
|
|
try {
|
|
const worldModPath = path.join(paths.getWorldModsPath(worldDir), modName);
|
|
await fs.access(worldModPath);
|
|
|
|
const worldConfigPath = paths.getWorldConfigPath(worldDir);
|
|
const worldConfig = await ConfigParser.parseWorldConfig(worldConfigPath);
|
|
|
|
installedWorlds.push({
|
|
name: worldDir,
|
|
displayName: worldConfig.server_name || worldDir
|
|
});
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
const modDetails = {
|
|
name: modName,
|
|
title: config.title || modName,
|
|
description: config.description || '',
|
|
author: config.author || '',
|
|
depends: config.depends || [],
|
|
optional_depends: config.optional_depends || [],
|
|
min_minetest_version: config.min_minetest_version || '',
|
|
max_minetest_version: config.max_minetest_version || '',
|
|
location: 'global',
|
|
path: modPath,
|
|
fileCount,
|
|
totalSize,
|
|
created: stats.birthtime,
|
|
lastModified: stats.mtime,
|
|
installedWorlds: installedWorlds,
|
|
config: config
|
|
};
|
|
|
|
res.render('mods/details', {
|
|
title: `Mod: ${modDetails.title}`,
|
|
mod: modDetails,
|
|
currentPage: 'mods'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting mod details:', error);
|
|
res.status(500).render('error', {
|
|
error: 'Failed to load mod details',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = router; |