diff --git a/packages/create/README.md b/packages/create/README.md new file mode 100644 index 00000000..0bdb45b1 --- /dev/null +++ b/packages/create/README.md @@ -0,0 +1,5 @@ +# Clusterio installer + +Installer for Clusterio. + +See [the Clusterio README.md](https://github.com/clusterio/factorioClusterio#readme) the for installation guide. diff --git a/packages/create/create.js b/packages/create/create.js new file mode 100755 index 00000000..4cb32463 --- /dev/null +++ b/packages/create/create.js @@ -0,0 +1,529 @@ +#!/usr/bin/env node + +"use strict"; +const child_process = require("child_process"); +const fs = require("fs-extra"); +const inquirer = require("inquirer"); +const os = require("os"); +const path = require("path"); +const phin = require("phin"); +const stream = require("stream"); +const util = require("util"); +const yargs = require("yargs"); + +const { levels, logger, setLogLevel } = require("./logging"); +let dev = false; +const scriptExt = process.platform === "win32" ? ".cmd" : ""; + + +const availablePlugins = [ + { name: "Global Chat", value: "@clusterio/plugin-global_chat" }, + { name: "Player Auth", value: "@clusterio/plugin-player_auth" }, + { name: "Research Sync", value: "@clusterio/plugin-research_sync" }, + { name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" }, + { name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" }, + + // Comunity plugins + { name: "Discord Bridge", value: "@hornwitser/discord_bridge" }, + { name: "Server Select", value: "@hornwitser/server_select" }, +]; + +const factorioLocations = { + win32: [ + "C:\\Program Files\\Factorio", + "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio", + ], + darwin: [ + "/Applications/factorio.app/Contents", + ], +}; + + +class LineSplitter extends stream.Transform { + constructor(options) { + super(options); + this._partial = null; + } + + _transform(chunk, encoding, callback) { + if (this._partial) { + chunk = Buffer.concat([this._partial, chunk]); + this._partial = null; + } + + while (chunk.length) { + let end = chunk.indexOf("\n"); + if (end === -1) { + this._partial = chunk; + break; + } + + let next = end + 1; + // Eat carriage return as well if present + if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) { + end -= 1; + } + + let line = chunk.slice(0, end); + chunk = chunk.slice(next); + this.push(line); + } + callback(); + } + + _flush(callback) { + if (this._partial) { + this.push(this._partial); + this._partial = null; + } + callback(); + } +} + +class InstallError extends Error { } + + +async function execFile(cmd, args) { + logger.verbose(`executing ${cmd} ${args.join(" ")}`); + const asyncExec = util.promisify(child_process.execFile); + return new Promise((resolve, reject) => { + let child = child_process.execFile(cmd, args, (err, stdout, stderr) => { + if (err) { + reject(err); + } else { + resolve({ stdout, stderr }); + } + }); + let stdout = new LineSplitter({ readableObjectMode: true }); + stdout.on("data", line => { logger.verbose(line.toString()); }); + child.stdout.pipe(stdout); + let stderr = new LineSplitter({ readableObjectMode: true }); + stderr.on("data", line => { logger.verbose(`err: ${line.toString()}`); }); + child.stderr.pipe(stderr); + }); +} + +async function execMaster(args) { + if (dev) { + return await execFile("node", [path.join("packages", "master"), ...args]); + } + return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args); +} + +async function execSlave(args) { + if (dev) { + return await execFile("node", [path.join("packages", "slave"), ...args]); + } + return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args); +} + +function validateSlaveToken(token) { + let parts = token.split("."); + if (parts.length !== 3) { + throw new InstallError("Invalid token"); + } + + let parsed; + try { + parsed = JSON.parse(Buffer.from(parts[1], "base64")); + } catch (err) { + throw new InstallError("Invalid token"); + } + + if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) { + throw new InstallError("Invalid token"); + } +} + +async function validateInstallDir() { + let entries = new Set(await fs.readdir(".")); + if (entries.size) { + if (!entries.has("package.json")) { + throw new InstallError("Refusing to install to non-empty directory"); + } + + let packageData; + try { + packageData = JSON.parse(await fs.readFile("package.json")); + } catch (err) { + throw new InstallError(`Failed to read package.json: ${err.message}`); + } + if (packageData.name !== "clusterio-install" || !packageData.private) { + throw new InstallError("Refusing to run on non-clusterio installation"); + } + } +} + +async function installClusterio(mode, plugins) { + try { + await fs.writeFile("package.json", JSON.stringify({ + name: "clusterio-install", + private: true, + }, null, 2), { flag: "wx" }); + } catch (err) { + if (err.code !== "EEXIST") { + throw new InstallError(`Failed to write package.json: ${err.message}`); + } + } + + let components = []; + if (["standalone", "master"].includes(mode)) { + components.push("@clusterio/master"); + } + if (["standalone", "slave"].includes(mode)) { + components.push("@clusterio/slave"); + } + if (mode === "ctl") { + components.push("@clusterio/ctl"); + } + + logger.info(`Please wait, installing ${mode}`); + try { + await execFile(`npm${scriptExt}`, ["install", ...components, ...plugins]); + } catch (err) { + throw new InstallError(`Failed to install: ${err.message}`); + } + + if (plugins.length) { + logger.info("Setting up plugins"); + let pluginList; + try { + pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json"))); + } catch (err) { + if (err.code === "ENOENT") { + pluginList = new Map(); + } else { + throw new InstallError(`Error loading plugin-list.json: ${err.message}`); + } + } + for (let plugin of plugins) { + if (!pluginList.has(plugin)) { + // eslint-disable-next-line node/global-require + let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] })); + pluginList.set(pluginInfo.name, plugin); + } + } + try { + await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4)); + } catch (err) { + throw new InstallError(`Error writing plugin-list.json: ${err.message}`); + } + } +} + +async function writeScripts(mode) { + if (["standalone", "master"].includes(mode)) { + if (process.platform === "win32") { + await fs.writeFile( + "run-master.cmd", + "@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n" + ); + } else { + await fs.writeFile( + "run-master.sh", + "#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n", + { mode: 0o755 }, + ); + await fs.outputFile( + "systemd/clusteriomaster.service", + `[Unit] +Description=Clusterio Master + +[Service] +User=${os.userInfo().username} +Group=nogroup +WorkingDirectory=${process.cwd()} +KillMode=mixed +KillSignal=SIGINT +ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn + +[Install] +WantedBy=multi-user.target +`); + } + } + + if (["standalone", "slave"].includes(mode)) { + if (process.platform === "win32") { + await fs.writeFile( + "run-slave.cmd", + "@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n" + ); + } else { + await fs.writeFile( + "run-slave.sh", + "#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n", + { mode: 0o755 }, + ); + await fs.outputFile( + "systemd/clusterioslave.service", + `[Unit] +Description=Clusterio Slave + +[Service] +User=${os.userInfo().username} +Group=nogroup +WorkingDirectory=${process.cwd()} +KillMode=mixed +KillSignal=SIGINT +ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn + +[Install] +WantedBy=multi-user.target +`); + } + } +} + +async function inquirerMissingArgs(args) { + let answers = {}; + if (args.mode) { answers.mode = args.mode; } + answers = await inquirer.prompt([ + { + type: "list", + name: "mode", + message: "Operating mode to install", + default: "standalone", + choices: [ + { name: "Standalone (install both master and slave on this computer)", value: "standalone" }, + { name: "Master only", value: "master" }, + { name: "Slave only", value: "slave" }, + { name: "Ctl only", value: "ctl" }, + { name: "Plugins only", value: "plugins" }, + ], + }, + ], answers); + + if (["standalone", "master"].includes(answers.mode)) { + if (args.admin) { answers.admin = args.admin; } + answers = await inquirer.prompt([ + { + type: "input", + name: "admin", + message: "Admin account name", + validate: input => { + if (!input) { + return "May not be empty"; + } + return true; + }, + }, + ], answers); + } + + if (answers.mode === "slave") { + if (args.slaveName) { answers.slaveName = args.slaveName; } + answers = await inquirer.prompt([ + { + type: "input", + name: "slaveName", + message: "Name of slave", + }, + ], answers); + } + + if (["slave", "ctl"].includes(answers.mode)) { + if (args.masterUrl) { answers.masterUrl = args.masterUrl; } + answers = await inquirer.prompt([ + { + type: "input", + name: "masterUrl", + message: "Master server URL", + }, + ], answers); + + if (args.masterToken) { answers.masterToken = args.masterToken; } + answers = await inquirer.prompt([ + { + type: "input", + name: "masterToken", + message: "Master authentication Token", + }, + ], answers); + } + + if (answers.mode === "slave") { + validateSlaveToken(answers.masterToken); + } + + if (["standalone", "slave"].includes(answers.mode)) { + let myIp; + if (args.publicAddress) { + answers.publicAddress = args.publicAddress; + } else { + try { + let result = await phin("https://api.ipify.org/"); + myIp = result.body.toString(); + } catch (err) { /* ignore */ } + } + answers = await inquirer.prompt([ + { + type: "input", + name: "publicAddress", + message: "Public DNS/IP address of this server", + default: myIp, + }, + ], answers); + + if (args.factorioDir) { answers.factorioDir = args.factorioDir; } + let locations = factorioLocations[process.platform] || []; + let foundLocations = []; + for (let location of locations) { + if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) { + foundLocations.push(location); + } + } + + answers = await inquirer.prompt([ + { + type: "list", + name: "factorioDir", + message: "Path to Factorio installation", + choices: [ + ...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })), + { name: "Use local factorio directory, you must copy an installation to it", value: "factorio" }, + { name: "Provide path manually", value: null }, + ], + }, + ], answers); + + if (answers.factorioDir === null) { + answers = await inquirer.prompt([ + { + type: "input", + name: "factorioDir", + message: "Path to Factorio installation", + askAnswered: true, + }, + ], answers); + } else if (answers.factorioDir === "factorio") { + await fs.ensureDir("factorio"); + } + } + + if (dev) { answers.plugins = []; } + answers = await inquirer.prompt([ + { + type: "checkbox", + name: "plugins", + message: "Plugins to install", + choices: availablePlugins, + pageSize: 20, + }, + ], answers); + + return answers; +} + + +async function main() { + const args = yargs + .option("log-level", { + nargs: 1, describe: "Log level to print to stdout", default: "info", + choices: ["none"].concat(Object.keys(levels)), type: "string", + }) + .option("dev", { + nargs: 0, describe: "Initialize development repository", hidden: true, default: false, type: "boolean", + }) + .option("mode", { + nargs: 1, describe: "Operating mode to install", + choices: ["standalone", "master", "slave", "ctl", "plugins"], + }) + .option("admin", { + nargs: 1, describe: "Admin account name [standalone/master]", type: "string", + }) + .option("slave-name", { + nargs: 1, describe: "Slave name [slave]", type: "string", + }) + .option("master-url", { + nargs: 1, describe: "Master URL [slave/ctl]", type: "string", + }) + .option("master-token", { + nargs: 1, describe: "Master authentication token [slave/ctl]", type: "string", + }) + .option("public-address", { + nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/slave]", type: "string", + }) + .option("factorio-dir", { + nargs: 1, describe: "Path to Factorio installation [standalone/slave]", type: "string", + }) + .option("plugins", { + array: true, describe: "Plugins to install", type: "string", + }) + .argv + ; + + setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]); + dev = args.dev; + + if (!dev) { + await validateInstallDir(); + } + + let answers = await inquirerMissingArgs(args); + logger.verbose(JSON.stringify(answers)); + + if (!dev) { + await installClusterio(answers.mode, answers.plugins); + } + + let adminToken = null; + if (["standalone", "master"].includes(answers.mode)) { + logger.info("Setting up master"); + await execMaster(["bootstrap", "create-admin", answers.admin]); + let result = await execMaster(["bootstrap", "generate-user-token", answers.admin]); + adminToken = result.stdout.split("\n").slice(-2)[0]; + } + + if (answers.mode === "standalone") { + logger.info("Setting up slave"); + await execSlave(["config", "set", "slave.name", "local"]); + + let result = await execSlave(["config", "show", "slave.id"]); + let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10); + + result = await execMaster(["bootstrap", "generate-slave-token", slaveId]); + let slaveToken = result.stdout.split("\n").slice(-2)[0]; + + await execSlave(["config", "set", "slave.master_token", slaveToken]); + await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]); + } + + if (answers.mode === "slave") { + logger.info("Setting up slave"); + let slaveId = JSON.parse(Buffer.from(answers.masterToken.split(".")[1], "base64")).slave; + await execSlave(["config", "set", "slave.id", slaveId]); + await execSlave(["config", "set", "slave.name", answers.slaveName]); + await execSlave(["config", "set", "slave.master_url", answers.masterUrl]); + await execSlave(["config", "set", "slave.master_token", answers.masterToken]); + await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]); + } + + if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) { + logger.info("Writing run scripts"); + await writeScripts(answers.mode); + } + + /* eslint-disable no-console */ + console.log(`Successfully installed ${answers.mode}`); + if (adminToken) { + console.log(`Admin authentication token: ${adminToken}`); + } + /* eslint-enable no-console */ +} + +if (module === require.main) { + main().catch(err => { + if (err instanceof InstallError) { + logger.error(err.message); + } else { + logger.fatal(` ++--------------------------------------------------------------+ +| Unexpected error occured installing clusterio, please report | +| it to https://github.com/clusterio/factorioClusterio/issues | ++--------------------------------------------------------------+ +${err.stack}` + ); + } + }); +} diff --git a/packages/create/logging.js b/packages/create/logging.js new file mode 100644 index 00000000..265dd8e1 --- /dev/null +++ b/packages/create/logging.js @@ -0,0 +1,51 @@ +/* eslint-disable no-console */ +"use strict"; +const chalk = require("chalk"); + +const levels = Object.freeze({ + fatal: 0, + error: 1, + warn: 2, + audit: 3, + info: 4, + verbose: 6, +}); + +const colors = { + fatal: "inverse red", + error: "bold redBright", + warn: "bold yellowBright", + audit: "bold greenBright", + info: "bold blueBright", + verbose: "bold grey", +}; + +let maxLogLevel = levels.info; +const logger = {}; +for (let [name, level] of Object.entries(levels)) { + // eslint-disable-next-line no-loop-func + logger[name] = message => { + if (level > maxLogLevel) { + return; + } + let style = chalk; + for (let part of colors[name].split(" ")) { + style = style[part]; + } + if (["fatal", "error"].includes(name)) { + console.error(`${style(name)} ${message}`); + } else if (["warn"].includes(name)) { + console.warn(`${style(name)} ${message}`); + } else { + console.log(`${style(name)} ${message}`); + } + }; +} + +module.exports = { + colors, + levels, + setLogLevel: (level) => { maxLogLevel = level; }, + logger, +}; + diff --git a/packages/create/package.json b/packages/create/package.json new file mode 100644 index 00000000..6e452f11 --- /dev/null +++ b/packages/create/package.json @@ -0,0 +1,27 @@ +{ + "name": "@clusterio/create", + "description": "Installer for clusterio", + "author": "Hornwitser", + "license": "MIT", + "version": "2.0.0-alpha.0", + "main": "create.js", + "bin": { + "clusteriocreate": "./create.js" + }, + "keywords": [ + "factorio", "clusterio" + ], + "engines": { + "node": ">=12" + }, + "dependencies": { + "chalk": "^4.1.0", + "fs-extra": "^8.1.0", + "inquirer": "^8.1.0", + "phin": "^3.5.1", + "yargs": "^14.2.0" + }, + "publishConfig": { + "access": "public" + } +}