diff --git a/crafty.js b/crafty.js index 40da564..612aeb3 100644 --- a/crafty.js +++ b/crafty.js @@ -1,7 +1,8 @@ const axios = require('axios'); const https = require('https'); const FormData = require('form-data'); -const fs = require('fs'); +const crypto = require('crypto'); +const fs = require('fs-extra'); const path = require('path'); class CraftyClient { @@ -46,40 +47,174 @@ class CraftyClient { return resp.data.data; } - async createServer(name, type = 'minecraft_java', memory = 2048, port = 25565) { + async listFiles(serverId, remotePath = 'mods') { + try { + const targetPath = remotePath.replace(/^\//, ''); + // In Crafty 4, file listing is often just GET /servers/{id}/files + // or /servers/{id}/files/list. We'll try the most standard one first. + const resp = await this.client.get(`/servers/${serverId}/files?path=${encodeURIComponent(targetPath)}`); + return resp.data.data || []; + } catch (err) { + // Fallback for different Crafty versions + try { + const resp = await this.client.get(`/servers/${serverId}/files/list?path=${encodeURIComponent(remotePath)}`); + return resp.data.data || []; + } catch (e) { + return []; + } + } + } + + async createServer(name, serverType = 'paper', memory = 2048, port = 25565, version = '1.20.1') { + const memGB = Math.floor(memory / 1024) || 1; const payload = { - server_name: name, - server_type: type, - mem_limit: memory, - server_port: port, - autostart: false + "name": name, + "monitoring_type": "minecraft_java", + "minecraft_java_monitoring_data": { + "host": "127.0.0.1", + "port": port + }, + "create_type": "minecraft_java", + "minecraft_java_create_data": { + "create_type": "download_jar", + "download_jar_create_data": { + "category": "mc_java_servers", + "type": serverType, + "version": version, + "mem_min": memGB, + "mem_max": memGB, + "server_properties_port": port + } + } }; const resp = await this.client.post('/servers', payload); + const data = resp.data.data; + + // Handle variations in ID property name + if (data && !data.server_id) { + data.server_id = data.new_server_id || data.id || data.serverID || data.uuid; + } + + return data; + } + + async installServer(serverId) { + const resp = await this.client.post(`/servers/${serverId}/install`); + return resp.data; + } + + async runAction(serverId, action) { + // Common actions: start_server, stop_server, restart_server, kill_server + const resp = await this.client.post(`/servers/${serverId}/action/${action}`); + return resp.data; + } + + async getServerStatus(serverId) { + const resp = await this.client.get(`/servers/${serverId}`); return resp.data.data; } + async createRemoteFile(serverId, fileName, content, remotePath = '') { + const tempPath = path.join(process.cwd(), `temp_${fileName}`); + await fs.writeFile(tempPath, content); + try { + await this.uploadFile(serverId, tempPath, remotePath); + } finally { + await fs.remove(tempPath); + } + } + + async ensureDirectory(serverId, remotePath) { + try { + const targetPath = remotePath.replace(/^\//, ''); + // Attempt to create folder using both common endpoints + await this.client.post(`/servers/${serverId}/files/folder`, { path: targetPath }).catch(() => { + return this.client.post(`/servers/${serverId}/files`, { action: 'mkdir', path: targetPath }); + }); + } catch (err) { + // Ignore errors if directory already exists + } + } + async uploadFile(serverId, localFilePath, remotePath = 'mods') { const stats = fs.statSync(localFilePath); const fileName = path.basename(localFilePath); const fileId = Math.random().toString(36).substring(2, 15); - - // Remove leading slash for 'location' header const targetLocation = remotePath.replace(/^\//, ''); - console.log(` Sending raw binary to Crafty (${(stats.size / 1024 / 1024).toFixed(2)} MB)...`); + await this.ensureDirectory(serverId, targetLocation); - const resp = await this.client.post(`/servers/${serverId}/files/upload`, fs.createReadStream(localFilePath), { - headers: { - 'Content-Type': 'application/octet-stream', - 'location': targetLocation, - 'fileName': fileName, - 'fileSize': stats.size.toString(), - 'fileId': fileId - }, - maxContentLength: Infinity, - maxBodyLength: Infinity + const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB + const totalChunks = Math.ceil(stats.size / CHUNK_SIZE); + + console.log(` Syncing ${fileName} to Crafty (${(stats.size / 1024 / 1024).toFixed(2)} MB)...`); + + if (stats.size > CHUNK_SIZE) { + console.log(` [Chunked Upload] Splitting into ${totalChunks} parts...`); + + // Initial handshake request (no chunkId) + await this.client.post(`/servers/${serverId}/files/upload`, null, { + headers: { + 'chunked': 'true', + 'fileId': fileId, + 'fileName': fileName, + 'fileSize': stats.size.toString(), + 'totalChunks': totalChunks.toString(), + 'location': targetLocation + } + }); + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, stats.size); + const chunkBody = await this.readChunk(localFilePath, start, end); + + // Calculate SHA-256 hash of the chunk + const hash = crypto.createHash('sha256').update(chunkBody).digest('hex'); + + process.stdout.write(` Uploading part ${i + 1}/${totalChunks}... `); + await this.client.post(`/servers/${serverId}/files/upload`, chunkBody, { + headers: { + 'Content-Type': 'application/octet-stream', + 'location': targetLocation, + 'fileName': fileName, + 'fileSize': stats.size.toString(), + 'fileId': fileId, + 'chunked': 'true', + 'chunkId': i.toString(), + 'totalChunks': totalChunks.toString(), + 'chunkHash': hash + }, + maxContentLength: Infinity, + maxBodyLength: Infinity + }); + console.log('āœ…'); + } + } else { + // Small file - single upload + await this.client.post(`/servers/${serverId}/files/upload`, fs.createReadStream(localFilePath), { + headers: { + 'Content-Type': 'application/octet-stream', + 'location': targetLocation, + 'fileName': fileName, + 'fileSize': stats.size.toString(), + 'fileId': fileId + }, + maxContentLength: Infinity, + maxBodyLength: Infinity + }); + } + return { status: 'ok' }; + } + + async readChunk(filePath, start, end) { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath, { start, end: end - 1 }); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', (err) => reject(err)); }); - return resp.data; } } diff --git a/index.js b/index.js index 20d59c7..aa8b7b0 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ const CurseForgeHandler = require('./curseforge'); async function main() { console.log('--- Crafty Controller Mod Installer ---'); - // 1. Initial Checks + // Configuration check if (!process.env.CRAFTY_URL || !process.env.CRAFTY_API_TOKEN) { console.error('Error: Please configure CRAFTY_URL and CRAFTY_API_TOKEN in .env file.'); process.exit(1); @@ -24,7 +24,7 @@ async function main() { if (!connected) throw new Error('Could not connect to Crafty Controller.'); console.log('āœ… Connected to Crafty Controller.'); - // 2. Setup Mode + // Networking & Connection Mode const { isLocal } = await inquirer.prompt([ { type: 'confirm', @@ -47,7 +47,7 @@ async function main() { serverPath = pathInput; } - // 3. Server Selection + // Server Selection logic const { serverAction } = await inquirer.prompt([ { type: 'list', @@ -56,15 +56,29 @@ async function main() { choices: ['Create New Server', 'Use Existing Server'] } ]); - let targetServerId = ''; + let needsInit = false; + if (serverAction === 'Create New Server') { - const { name, port, memory } = await inquirer.prompt([ + needsInit = true; + const { name, version, type, port, memory } = await inquirer.prompt([ { type: 'input', name: 'name', message: 'Server Name:', default: 'Modded Server' }, + { type: 'input', name: 'version', message: 'Minecraft Version:', default: '1.20.1' }, + { + type: 'list', + name: 'type', + message: 'Server Type:', + choices: ['vanilla', 'paper', 'forge', 'fabric', 'quilt'], + default: 'fabric' + }, { type: 'number', name: 'port', message: 'Port:', default: 25565 }, { type: 'number', name: 'memory', message: 'Memory (MB):', default: 4096 } ]); - const newServer = await crafty.createServer(name, 'minecraft_java', memory, port); + const newServer = await crafty.createServer(name, type, memory, port, version); + if (!newServer || !newServer.server_id) { + console.log('Creation response:', JSON.stringify(newServer)); + throw new Error('Server created but could not retrieve Server ID.'); + } targetServerId = newServer.server_id; console.log(`āœ… Server created: ${name} (ID: ${targetServerId})`); } else { @@ -80,7 +94,25 @@ async function main() { targetServerId = selection; } - // 4. Mod Selection Loop + // 3.5 Server Initialization Phase (Only for NEW servers) + if (needsInit) { + console.log('\n--- šŸ› ļø Server Setup Required ---'); + console.log('1. Go to your Crafty Controller Web UI.'); + console.log('2. Start the server and accept the EULA.'); + console.log('3. Wait for the server to generate its files (look for the "mods" folder).'); + console.log('4. STOP the server once it is initialized.'); + + await inquirer.prompt([ + { + type: 'input', + name: 'continue', + message: 'Press ENTER once the server files have been generated and the server is STOPPED...' + } + ]); + console.log('āœ… Continuing with mod installation...\n'); + } + + // Mod selection and sync loop let keepInstalling = true; while (keepInstalling) { const { platform } = await inquirer.prompt([ @@ -100,7 +132,7 @@ async function main() { } ]); - // 5. Installation + // Project preparation console.log('šŸ”„ Preparing installation...'); // Determine final target directory @@ -121,9 +153,9 @@ async function main() { type: 'list', name: 'versionSelection', message: 'Select a version to install:', - choices: versions.slice(0, 10).map(v => ({ - name: `${v.version_number} [MC: ${v.game_versions.join(', ')}] (${v.loaders.join(', ')})`, - value: v.id + choices: versions.slice(0, 10).map(v => ({ + name: `${v.version_number} [MC: ${v.game_versions.join(', ')}] (${v.loaders.join(', ')})`, + value: v.id })) } ]); @@ -136,9 +168,9 @@ async function main() { type: 'list', name: 'fileSelection', message: 'Select a file to install:', - choices: files.slice(0, 10).map(f => ({ - name: `${f.displayName} [MC: ${f.gameVersions.join(', ')}]`, - value: f.id + choices: files.slice(0, 10).map(f => ({ + name: `${f.displayName} [MC: ${f.gameVersions.join(', ')}]`, + value: f.id })) } ]); @@ -149,14 +181,34 @@ async function main() { console.log('šŸš€ Remote mode: Syncing files to Crafty...'); const modsDir = path.join(targetDir, 'mods'); if (await fs.pathExists(modsDir)) { + const existingFiles = await crafty.listFiles(targetServerId, 'mods'); + const existingNames = existingFiles.map(f => f.name); + const files = await fs.readdir(modsDir); for (const file of files) { + if (existingNames.includes(file)) { + console.log(` Skipping ${file} (already exists)`); + continue; + } console.log(` Uploading ${file} to Crafty...`); await crafty.uploadFile(targetServerId, path.join(modsDir, file), 'mods'); } + + // Verification Check + console.log('\nšŸ” Verifying installation on Crafty...'); + const finalRemoteFiles = await crafty.listFiles(targetServerId, 'mods'); + const finalRemoteNames = finalRemoteFiles.map(f => f.name); + const missing = files.filter(f => !finalRemoteNames.includes(f)); + + if (missing.length === 0) { + console.log(`āœ… All ${files.length} mods are successfully installed and verified on the server!`); + } else { + console.log(`āš ļø Verification failed! ${missing.length} / ${files.length} mods are missing:`); + missing.forEach(m => console.log(` - ${m}`)); + } } console.log('āœ… Remote sync complete!'); - await fs.remove(targetDir); + await fs.remove(targetDir); } else { console.log(`āœ… Success! Files installed to: ${targetDir}`); }