mirror of
https://github.com/noothenoot/crafty-mod-installer.git
synced 2026-07-21 21:37:00 +00:00
Refined server initialization and added chunked upload support with integrity hashing
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const FormData = require('form-data');
|
const FormData = require('form-data');
|
||||||
const fs = require('fs');
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs-extra');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
class CraftyClient {
|
class CraftyClient {
|
||||||
@@ -46,40 +47,174 @@ class CraftyClient {
|
|||||||
return resp.data.data;
|
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 = {
|
const payload = {
|
||||||
server_name: name,
|
"name": name,
|
||||||
server_type: type,
|
"monitoring_type": "minecraft_java",
|
||||||
mem_limit: memory,
|
"minecraft_java_monitoring_data": {
|
||||||
server_port: port,
|
"host": "127.0.0.1",
|
||||||
autostart: false
|
"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 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;
|
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') {
|
async uploadFile(serverId, localFilePath, remotePath = 'mods') {
|
||||||
const stats = fs.statSync(localFilePath);
|
const stats = fs.statSync(localFilePath);
|
||||||
const fileName = path.basename(localFilePath);
|
const fileName = path.basename(localFilePath);
|
||||||
const fileId = Math.random().toString(36).substring(2, 15);
|
const fileId = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
// Remove leading slash for 'location' header
|
|
||||||
const targetLocation = remotePath.replace(/^\//, '');
|
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), {
|
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
headers: {
|
const totalChunks = Math.ceil(stats.size / CHUNK_SIZE);
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'location': targetLocation,
|
console.log(` Syncing ${fileName} to Crafty (${(stats.size / 1024 / 1024).toFixed(2)} MB)...`);
|
||||||
'fileName': fileName,
|
|
||||||
'fileSize': stats.size.toString(),
|
if (stats.size > CHUNK_SIZE) {
|
||||||
'fileId': fileId
|
console.log(` [Chunked Upload] Splitting into ${totalChunks} parts...`);
|
||||||
},
|
|
||||||
maxContentLength: Infinity,
|
// Initial handshake request (no chunkId)
|
||||||
maxBodyLength: Infinity
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const CurseForgeHandler = require('./curseforge');
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('--- Crafty Controller Mod Installer ---');
|
console.log('--- Crafty Controller Mod Installer ---');
|
||||||
|
|
||||||
// 1. Initial Checks
|
// Configuration check
|
||||||
if (!process.env.CRAFTY_URL || !process.env.CRAFTY_API_TOKEN) {
|
if (!process.env.CRAFTY_URL || !process.env.CRAFTY_API_TOKEN) {
|
||||||
console.error('Error: Please configure CRAFTY_URL and CRAFTY_API_TOKEN in .env file.');
|
console.error('Error: Please configure CRAFTY_URL and CRAFTY_API_TOKEN in .env file.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -24,7 +24,7 @@ async function main() {
|
|||||||
if (!connected) throw new Error('Could not connect to Crafty Controller.');
|
if (!connected) throw new Error('Could not connect to Crafty Controller.');
|
||||||
console.log('✅ Connected to Crafty Controller.');
|
console.log('✅ Connected to Crafty Controller.');
|
||||||
|
|
||||||
// 2. Setup Mode
|
// Networking & Connection Mode
|
||||||
const { isLocal } = await inquirer.prompt([
|
const { isLocal } = await inquirer.prompt([
|
||||||
{
|
{
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
@@ -47,7 +47,7 @@ async function main() {
|
|||||||
serverPath = pathInput;
|
serverPath = pathInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Server Selection
|
// Server Selection logic
|
||||||
const { serverAction } = await inquirer.prompt([
|
const { serverAction } = await inquirer.prompt([
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
@@ -56,15 +56,29 @@ async function main() {
|
|||||||
choices: ['Create New Server', 'Use Existing Server']
|
choices: ['Create New Server', 'Use Existing Server']
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let targetServerId = '';
|
let targetServerId = '';
|
||||||
|
let needsInit = false;
|
||||||
|
|
||||||
if (serverAction === 'Create New Server') {
|
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: '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: 'port', message: 'Port:', default: 25565 },
|
||||||
{ type: 'number', name: 'memory', message: 'Memory (MB):', default: 4096 }
|
{ 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;
|
targetServerId = newServer.server_id;
|
||||||
console.log(`✅ Server created: ${name} (ID: ${targetServerId})`);
|
console.log(`✅ Server created: ${name} (ID: ${targetServerId})`);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,7 +94,25 @@ async function main() {
|
|||||||
targetServerId = selection;
|
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;
|
let keepInstalling = true;
|
||||||
while (keepInstalling) {
|
while (keepInstalling) {
|
||||||
const { platform } = await inquirer.prompt([
|
const { platform } = await inquirer.prompt([
|
||||||
@@ -100,7 +132,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 5. Installation
|
// Project preparation
|
||||||
console.log('🔄 Preparing installation...');
|
console.log('🔄 Preparing installation...');
|
||||||
|
|
||||||
// Determine final target directory
|
// Determine final target directory
|
||||||
@@ -121,9 +153,9 @@ async function main() {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
name: 'versionSelection',
|
name: 'versionSelection',
|
||||||
message: 'Select a version to install:',
|
message: 'Select a version to install:',
|
||||||
choices: versions.slice(0, 10).map(v => ({
|
choices: versions.slice(0, 10).map(v => ({
|
||||||
name: `${v.version_number} [MC: ${v.game_versions.join(', ')}] (${v.loaders.join(', ')})`,
|
name: `${v.version_number} [MC: ${v.game_versions.join(', ')}] (${v.loaders.join(', ')})`,
|
||||||
value: v.id
|
value: v.id
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@@ -136,9 +168,9 @@ async function main() {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
name: 'fileSelection',
|
name: 'fileSelection',
|
||||||
message: 'Select a file to install:',
|
message: 'Select a file to install:',
|
||||||
choices: files.slice(0, 10).map(f => ({
|
choices: files.slice(0, 10).map(f => ({
|
||||||
name: `${f.displayName} [MC: ${f.gameVersions.join(', ')}]`,
|
name: `${f.displayName} [MC: ${f.gameVersions.join(', ')}]`,
|
||||||
value: f.id
|
value: f.id
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@@ -149,14 +181,34 @@ async function main() {
|
|||||||
console.log('🚀 Remote mode: Syncing files to Crafty...');
|
console.log('🚀 Remote mode: Syncing files to Crafty...');
|
||||||
const modsDir = path.join(targetDir, 'mods');
|
const modsDir = path.join(targetDir, 'mods');
|
||||||
if (await fs.pathExists(modsDir)) {
|
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);
|
const files = await fs.readdir(modsDir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
if (existingNames.includes(file)) {
|
||||||
|
console.log(` Skipping ${file} (already exists)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.log(` Uploading ${file} to Crafty...`);
|
console.log(` Uploading ${file} to Crafty...`);
|
||||||
await crafty.uploadFile(targetServerId, path.join(modsDir, file), 'mods');
|
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!');
|
console.log('✅ Remote sync complete!');
|
||||||
await fs.remove(targetDir);
|
await fs.remove(targetDir);
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Success! Files installed to: ${targetDir}`);
|
console.log(`✅ Success! Files installed to: ${targetDir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user