mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
These packages are obsoleted by the promises API to the built-in Node fs module. The fs-extra package is also causing problems with broken APIs that don't behave the same as Node's fs module, and throwing errors without code properties on them.
1001 lines
29 KiB
JavaScript
Executable File
1001 lines
29 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
"use strict";
|
|
const child_process = require("child_process");
|
|
const fs = require("node:fs/promises");
|
|
const inquirer = require("inquirer");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const stream = require("stream");
|
|
const util = require("util");
|
|
const yargs = require("yargs");
|
|
|
|
const { levels, logger, setLogLevel } = require("./logging");
|
|
const { copyPluginTemplates } = require("./template");
|
|
let dev = false;
|
|
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
|
const finished = util.promisify(stream.finished);
|
|
const { escapeArg } = require("./escape_arg");
|
|
|
|
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
|
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
|
// One option is to not call rl.close: SBoudrias/Inquirer.js#767
|
|
// But this comes with its own issues: SBoudrias/Inquirer.js#899
|
|
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
|
if (process.platform === "win32") {
|
|
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
|
|
inquirer.ui.Prompt.prototype.close = function() {
|
|
this.rl.terminal = false;
|
|
_inquirer_close.call(this);
|
|
};
|
|
}
|
|
|
|
const availablePlugins = [
|
|
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
|
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
|
|
{ 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 pathExists(filePath) {
|
|
try {
|
|
await fs.access(filePath);
|
|
} catch (err) {
|
|
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function safeOutputFile(file, data, options={}) {
|
|
let { dir, name, ext } = path.parse(file);
|
|
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
|
if (dir) {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
await fs.writeFile(temporary, data, { ...options, flush: true });
|
|
await fs.rename(temporary, file);
|
|
}
|
|
|
|
async function execFile(cmd, args) {
|
|
const escaped = args.map(escapeArg);
|
|
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
|
|
return new Promise((resolve, reject) => {
|
|
let child = child_process.execFile(
|
|
cmd,
|
|
escaped,
|
|
{
|
|
shell: true,
|
|
|
|
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
|
},
|
|
(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 execController(args) {
|
|
if (dev) {
|
|
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
|
|
}
|
|
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
|
|
}
|
|
|
|
async function execHost(args) {
|
|
if (dev) {
|
|
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
|
|
}
|
|
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
|
|
}
|
|
|
|
async function execCtl(args) {
|
|
if (dev) {
|
|
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
|
|
}
|
|
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
|
}
|
|
|
|
function validateHostToken(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 !== "host" || !Number.isInteger(parsed.host)) {
|
|
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 validateNotRoot(args) {
|
|
if (args.allowInstallAsRoot) {
|
|
return;
|
|
}
|
|
|
|
if (os.userInfo().uid === 0) {
|
|
throw new InstallError(
|
|
"Refusing to install as root. Create a separate user account for clusterio " +
|
|
"and then use su/sudo to switch to it before invoking the installer."
|
|
);
|
|
}
|
|
}
|
|
|
|
async function migrateRename(args) {
|
|
function rename(string) {
|
|
return string.replace(/master/g, "controller").replace(/slave/g, "host");
|
|
}
|
|
|
|
function renameConfig(config) {
|
|
for (let group of config["groups"]) {
|
|
group["name"] = rename(group["name"]);
|
|
group["fields"] = Object.fromEntries(
|
|
Object.entries(group["fields"]).map(([k, v]) => [rename(k), v])
|
|
);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
async function migrateConfig(source, destination) {
|
|
if (await pathExists(source) && !await pathExists(destination)) {
|
|
await safeOutputFile(destination, JSON.stringify(
|
|
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
|
));
|
|
logger.info(`Migrated ${source} to ${destination}`);
|
|
}
|
|
}
|
|
|
|
await migrateConfig("config-master.json", "config-controller.json");
|
|
await migrateConfig("config-slave.json", "config-host.json");
|
|
|
|
let instancesFile = path.join("database", "instances.json");
|
|
if (await pathExists(instancesFile)) {
|
|
await safeOutputFile(instancesFile, JSON.stringify(
|
|
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
|
));
|
|
logger.info(`Migrated ${instancesFile}`);
|
|
}
|
|
|
|
let usersFile = path.join("database", "users.json");
|
|
if (await pathExists(usersFile)) {
|
|
let users = JSON.parse(await fs.readFile(usersFile));
|
|
for (let role of users["roles"]) {
|
|
role["permissions"] = role["permissions"].map(rename);
|
|
}
|
|
await safeOutputFile(usersFile, JSON.stringify(users, null, "\t"));
|
|
logger.info(`Migrated ${usersFile}`);
|
|
}
|
|
|
|
function renameLogLine(info) {
|
|
return Object.fromEntries(
|
|
Object.entries(info).map(([k, v]) => [rename(k), v])
|
|
);
|
|
}
|
|
|
|
async function migrateLog(inputFile, outputFile) {
|
|
let lineStream = new LineSplitter({ readableObjectMode: true });
|
|
let fileStream = (await fs.open(inputFile)).createReadStream();
|
|
fileStream.pipe(lineStream);
|
|
let lines = [];
|
|
|
|
for await (let inputLine of lineStream) {
|
|
try {
|
|
lines.push(Buffer.from(JSON.stringify(renameLogLine(JSON.parse(inputLine)))));
|
|
lines.push(Buffer.from("\n"));
|
|
} catch (err) {
|
|
if (!(err instanceof SyntaxError)) {
|
|
throw err;
|
|
}
|
|
logger.warn(`Invalid log line: ${inputLine.toString()}`);
|
|
lines.push(inputLine);
|
|
}
|
|
}
|
|
safeOutputFile(outputFile, Buffer.concat(lines));
|
|
}
|
|
|
|
async function migrateLogsDir(inputLogs, outputLogs) {
|
|
if (await pathExists(inputLogs)) {
|
|
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
|
|
for (let logFile of await fs.readdir(inputLogs)) {
|
|
if (!logFile.endsWith(".log")) {
|
|
continue;
|
|
}
|
|
let inputFile = path.join(inputLogs, logFile);
|
|
let outputFile = path.join(outputLogs, rename(logFile));
|
|
if (!await pathExists(outputFile)) {
|
|
await migrateLog(inputFile, outputFile);
|
|
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
|
|
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
|
|
if (
|
|
await pathExists(path.join("logs", "cluster"))
|
|
&& !await pathExists(path.join("logs", "cluster-prerename"))
|
|
) {
|
|
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
|
|
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
|
}
|
|
|
|
if (
|
|
await pathExists("sharedMods")
|
|
&& !await pathExists("mods")
|
|
) {
|
|
logger.info("Moving sharedMods/ to mods/");
|
|
await fs.rename("sharedMods", "mods");
|
|
}
|
|
|
|
if (!args.dev) {
|
|
let pkg = JSON.parse(await fs.readFile("package.json"));
|
|
if (pkg.dependencies) {
|
|
let convert = ["@clusterio/master", "@clusterio/slave"];
|
|
let uninstall = convert.filter(c => pkg.dependencies[c] !== undefined);
|
|
let install = uninstall.map(rename);
|
|
if (uninstall.length) {
|
|
logger.info(`Replacing ${uninstall.join(" and ")}`);
|
|
await execFile(`npm${scriptExt}`, ["install", ...install]);
|
|
await execFile(`npm${scriptExt}`, ["uninstall", ...uninstall]);
|
|
}
|
|
}
|
|
logger.info("Updating packages");
|
|
await execFile(`npm${scriptExt}`, ["update"]);
|
|
}
|
|
|
|
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
|
|
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
|
|
if (hasRunMaster || hasRunSlave) {
|
|
logger.info("Writing run scripts");
|
|
let mode = "standalone";
|
|
if (!hasRunSlave) { mode = "controller"; }
|
|
if (!hasRunMaster) { mode = "host"; }
|
|
await writeScripts(mode, "");
|
|
}
|
|
|
|
logger.info(
|
|
"Migration complete, you may now delete the following left over files and directories (if present):" +
|
|
"\n- config-master.json" +
|
|
"\n- config-slave.json" +
|
|
"\n- logs/master" +
|
|
"\n- logs/slave" +
|
|
"\n- logs/cluster-prerename" +
|
|
"\n- systemd/clusteriomaster.service" +
|
|
"\n- systemd/clusterioslave.service" +
|
|
"\n- run-master.sh / run-master.cmd" +
|
|
"\n- run-slave.sh / run-slave.cmd"
|
|
);
|
|
}
|
|
|
|
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", "controller"].includes(mode)) {
|
|
components.push("@clusterio/controller");
|
|
}
|
|
if (["standalone", "host"].includes(mode)) {
|
|
components.push("@clusterio/host");
|
|
}
|
|
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)) {
|
|
|
|
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
|
pluginList.set(pluginInfo.name, plugin);
|
|
}
|
|
}
|
|
try {
|
|
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
|
|
} catch (err) {
|
|
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function groupIdToName(gid) {
|
|
try {
|
|
let exec = util.promisify(child_process.exec);
|
|
let { stdout } = await exec(`getent group ${gid}`);
|
|
return stdout.split(":")[0];
|
|
} catch (err) {
|
|
logger.warn(`getent group ${gid} failed: ${err.message}`);
|
|
return gid;
|
|
}
|
|
}
|
|
|
|
async function writeScripts(mode, selfSignedCert) {
|
|
if (["standalone", "controller"].includes(mode)) {
|
|
if (process.platform === "win32") {
|
|
await safeOutputFile(
|
|
"run-controller.cmd",
|
|
`\
|
|
@echo off
|
|
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
|
:restart
|
|
call .\\node_modules\\.bin\\clusteriocontroller.cmd run --can-restart
|
|
if %errorlevel% equ 0 exit /b
|
|
if %errorlevel% equ 8 exit /b
|
|
goto restart
|
|
`
|
|
);
|
|
} else {
|
|
await safeOutputFile(
|
|
"run-controller.sh",
|
|
`\
|
|
#!/bin/bash
|
|
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
|
while true; do
|
|
./node_modules/.bin/clusteriocontroller run --can-restart
|
|
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
|
done
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
await safeOutputFile(
|
|
"systemd/clusteriocontroller.service",
|
|
`\
|
|
[Unit]
|
|
Description=Clusterio Controller
|
|
|
|
[Service]
|
|
User=${os.userInfo().username}
|
|
Group=${await groupIdToName(os.userInfo().gid)}
|
|
WorkingDirectory=${process.cwd()}
|
|
KillMode=mixed
|
|
KillSignal=SIGINT
|
|
Environment=NODE_OPTIONS=--enable-source-maps
|
|
ExecStart=${process.cwd()}/node_modules/.bin/clusteriocontroller run --log-level=warn --can-restart
|
|
Restart=on-failure
|
|
RestartPreventExitStatus=8
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (["standalone", "host"].includes(mode)) {
|
|
if (process.platform === "win32") {
|
|
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
|
await safeOutputFile(
|
|
"run-host.cmd",
|
|
`\
|
|
@echo off
|
|
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
|
${extraCaCertsLine}:restart
|
|
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
|
|
if %errorlevel% equ 0 exit /b
|
|
if %errorlevel% equ 8 exit /b
|
|
goto restart
|
|
`
|
|
);
|
|
} else {
|
|
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
|
await safeOutputFile(
|
|
"run-host.sh",
|
|
`\
|
|
#!/bin/bash
|
|
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
|
${extraCaCertsLine}while true; do
|
|
./node_modules/.bin/clusteriohost run --can-restart
|
|
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
|
done
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
|
|
await safeOutputFile(
|
|
"systemd/clusteriohost.service",
|
|
`\
|
|
[Unit]
|
|
Description=Clusterio Host
|
|
|
|
[Service]
|
|
User=${os.userInfo().username}
|
|
Group=${await groupIdToName(os.userInfo().gid)}
|
|
WorkingDirectory=${process.cwd()}
|
|
KillMode=mixed
|
|
KillSignal=SIGINT
|
|
Environment=NODE_OPTIONS=--enable-source-maps
|
|
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
|
Restart=on-failure
|
|
RestartPreventExitStatus=8
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line complexity
|
|
async function inquirerMissingArgs(args) {
|
|
let answers = {};
|
|
if (args.mode) { answers.mode = args.mode; }
|
|
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "list",
|
|
name: "mode",
|
|
message: "Operating mode to install",
|
|
default: "standalone",
|
|
choices: [
|
|
{ name: "Standalone (install both controller and host on this computer)", value: "standalone" },
|
|
{ name: "Controller only", value: "controller" },
|
|
{ name: "Host only", value: "host" },
|
|
{ name: "Ctl only", value: "ctl" },
|
|
{ name: "Plugins only", value: "plugins" },
|
|
{ name: "Plugin template", value: "plugin-template" },
|
|
],
|
|
},
|
|
], answers);
|
|
|
|
if (["standalone", "controller"].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 (args.httpPort) { answers.httpPort = args.httpPort; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "input",
|
|
name: "httpPort",
|
|
message: "HTTP port to listen on",
|
|
default: 8080,
|
|
validate: input => {
|
|
if (!input) {
|
|
return "May not be empty";
|
|
}
|
|
if (!Number.isInteger(Number(input))) {
|
|
return "Must be a number";
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
], answers);
|
|
}
|
|
|
|
if (answers.mode === "host") {
|
|
if (args.hostName) { answers.hostName = args.hostName; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "input",
|
|
name: "hostName",
|
|
message: "Name of host",
|
|
},
|
|
], answers);
|
|
}
|
|
|
|
if (["host", "ctl"].includes(answers.mode)) {
|
|
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "input",
|
|
name: "controllerUrl",
|
|
message: "Controller URL",
|
|
},
|
|
], answers);
|
|
|
|
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "input",
|
|
name: "controllerToken",
|
|
message: "Controller authentication Token",
|
|
},
|
|
], answers);
|
|
}
|
|
|
|
if (answers.mode === "host") {
|
|
validateHostToken(answers.controllerToken);
|
|
}
|
|
|
|
if (["standalone", "host"].includes(answers.mode)) {
|
|
let myIp = "localhost";
|
|
if (args.publicAddress) {
|
|
answers.publicAddress = args.publicAddress;
|
|
} else {
|
|
try {
|
|
let result = await fetch("https://api.ipify.org/");
|
|
myIp = await result.text();
|
|
} 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 pathExists(path.join(location, "data", "changelog.txt"))) {
|
|
foundLocations.push(location);
|
|
}
|
|
}
|
|
if (process.platform === "linux") {
|
|
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
|
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "confirm",
|
|
name: "downloadHeadless",
|
|
message: "(Linux only) Automatically download latest factorio release?",
|
|
default: true,
|
|
},
|
|
], answers);
|
|
|
|
if (!answers.factorioDir && answers.downloadHeadless) {
|
|
answers.factorioDir = "factorio";
|
|
}
|
|
}
|
|
|
|
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.mkdir("factorio", { recursive: true });
|
|
}
|
|
}
|
|
|
|
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
|
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "confirm",
|
|
name: "remoteNpm",
|
|
message: "Allow remote updates via npm?",
|
|
default: true,
|
|
},
|
|
], answers);
|
|
}
|
|
|
|
if (answers.mode === "plugin-template") {
|
|
if (args["plugin-template"] && args["plugin-template"].length > 0) {
|
|
answers.pluginTemplate = args["plugin-template"];
|
|
}
|
|
if (args["plugin-name"]) {
|
|
answers.pluginName = args["plugin-name"];
|
|
}
|
|
answers.plugins = [];
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "checkbox",
|
|
name: "pluginTemplate",
|
|
message: "Plugin templates to use",
|
|
choices: [
|
|
{ name: "Controller", value: "controller" },
|
|
{ name: "Host", value: "host" },
|
|
{ name: "Instance", value: "instance" },
|
|
{ name: "Lua Module", value: "module" },
|
|
{ name: "Command Line", value: "ctl" },
|
|
{ name: "Web UI", value: "web" },
|
|
{ name: "No Typescript", value: "js" },
|
|
{ name: "No Config", value: "no_config" },
|
|
],
|
|
},
|
|
{
|
|
type: "input",
|
|
name: "pluginName",
|
|
message: "Name of your new plugin",
|
|
default: path.basename(path.resolve()),
|
|
},
|
|
], answers);
|
|
}
|
|
|
|
if (dev) {
|
|
answers.plugins = [];
|
|
} else if (args.plugins) {
|
|
answers.plugins = args.plugins;
|
|
}
|
|
answers = await inquirer.prompt([
|
|
{
|
|
type: "checkbox",
|
|
name: "plugins",
|
|
message: "Plugins to install",
|
|
choices: availablePlugins,
|
|
pageSize: 20,
|
|
},
|
|
], answers);
|
|
|
|
return answers;
|
|
}
|
|
|
|
async function downloadLinuxServer() {
|
|
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
|
|
|
// get the filename of the latest factorio archive from redirected url
|
|
const url = new URL(res.url); // The res is redirects, need final url
|
|
const filename = path.posix.basename(url.pathname);
|
|
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
|
if (!match || !match.length) {
|
|
throw Error(`Unable to extract version from filename: ${filename}`);
|
|
}
|
|
const version = match[0];
|
|
|
|
const tmpDir = "temp/create-temp/";
|
|
const archivePath = tmpDir + filename;
|
|
const tmpArchivePath = `${archivePath}.tmp`;
|
|
const factorioDir = `factorio/${version}/`;
|
|
const tmpFactorioDir = tmpDir + version;
|
|
|
|
if (await pathExists(factorioDir)) {
|
|
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
|
} else {
|
|
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(tmpDir, { recursive: true });
|
|
|
|
logger.info("Downloading latest Factorio server release. This may take a while.");
|
|
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
|
|
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
|
|
|
await finished(writeStream);
|
|
|
|
await fs.rename(tmpArchivePath, archivePath);
|
|
try {
|
|
await fs.mkdir(tmpFactorioDir, { recursive: true });
|
|
await execFile("tar", [
|
|
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
|
]);
|
|
} catch (e) {
|
|
logger.error("error executing command- do you have 'xz-utils' installed?");
|
|
throw e;
|
|
}
|
|
|
|
await fs.mkdir("factorio", { recursive: true });
|
|
await fs.rename(tmpFactorioDir, factorioDir);
|
|
await fs.unlink(archivePath);
|
|
await fs.rm(tmpDir);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
let 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", "controller", "host", "ctl", "plugins", "plugin-template"],
|
|
})
|
|
.option("migrate-rename", {
|
|
nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean",
|
|
})
|
|
.option("admin", {
|
|
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
|
|
})
|
|
.option("http-port", {
|
|
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
|
})
|
|
.option("download-headless", {
|
|
describe: "Download the latest headless release [standalone/controller] [linux only]",
|
|
type: "boolean", nargs: 0,
|
|
})
|
|
.option("host-name", {
|
|
nargs: 1, describe: "Host name [host]", type: "string",
|
|
})
|
|
.option("controller-url", {
|
|
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
|
|
})
|
|
.option("controller-token", {
|
|
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
|
|
})
|
|
.option("public-address", {
|
|
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
|
|
})
|
|
.option("factorio-dir", {
|
|
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
|
|
})
|
|
.option("self-signed-cert", {
|
|
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
|
|
})
|
|
.option("remote-npm", {
|
|
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
|
})
|
|
.option("plugins", {
|
|
array: true, describe: "Plugins to install", type: "string",
|
|
})
|
|
.option("plugin-template", {
|
|
array: true, describe: "Plugin template to use [plugin-template]", type: "string",
|
|
})
|
|
.option("plugin-name", {
|
|
nargs: 1, describe: "Name of your new plugin [plugin-template]", type: "string",
|
|
})
|
|
.strict()
|
|
;
|
|
|
|
if (process.platform === "linux") {
|
|
args = args
|
|
.option("allow-install-as-root", {
|
|
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
|
})
|
|
.option("download-headless", {
|
|
nargs: 0,
|
|
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
|
"Can be set to false using --no-download-headless.",
|
|
type: "boolean",
|
|
})
|
|
;
|
|
}
|
|
|
|
args = args.argv;
|
|
|
|
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
|
|
dev = args.dev;
|
|
|
|
if (!dev) {
|
|
await validateInstallDir();
|
|
await validateNotRoot(args);
|
|
}
|
|
|
|
if (args.migrateRename) {
|
|
await migrateRename(args);
|
|
return;
|
|
}
|
|
|
|
let answers = await inquirerMissingArgs(args);
|
|
logger.verbose(JSON.stringify(answers));
|
|
|
|
if (answers.pluginTemplate) {
|
|
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
|
|
return;
|
|
}
|
|
|
|
if (answers.downloadHeadless) {
|
|
if (answers.factorioDir !== "factorio") {
|
|
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
|
}
|
|
await downloadLinuxServer();
|
|
}
|
|
|
|
if (!dev) {
|
|
await installClusterio(answers.mode, answers.plugins);
|
|
}
|
|
|
|
let adminToken = null;
|
|
if (["standalone", "controller"].includes(answers.mode)) {
|
|
logger.info("Setting up controller");
|
|
await execController(["bootstrap", "create-admin", answers.admin]);
|
|
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
|
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
|
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
|
|
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
|
adminToken = result.stdout.split("\n").slice(-2)[0];
|
|
}
|
|
|
|
if (answers.mode === "standalone") {
|
|
logger.info("Setting up host");
|
|
await execHost(["config", "set", "host.name", "local"]);
|
|
|
|
let result = await execHost(["config", "show", "host.id"]);
|
|
let hostId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
|
|
|
result = await execController(["bootstrap", "generate-host-token", hostId]);
|
|
let hostToken = result.stdout.split("\n").slice(-2)[0];
|
|
|
|
// Default to localhost on correct port for host in standalone mode
|
|
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
|
|
await execHost(["config", "set", "host.controller_token", hostToken]);
|
|
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
|
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
|
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
|
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
|
}
|
|
|
|
if (answers.mode === "host") {
|
|
logger.info("Setting up host");
|
|
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
|
|
await execHost(["config", "set", "host.id", hostId]);
|
|
await execHost(["config", "set", "host.name", answers.hostName]);
|
|
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
|
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
|
|
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
|
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
|
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
|
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
|
}
|
|
|
|
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
|
logger.info("Writing run scripts");
|
|
await writeScripts(answers.mode, args.selfSignedCert);
|
|
}
|
|
|
|
if (answers.mode === "ctl") {
|
|
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
|
|
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
|
|
}
|
|
|
|
/* 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/clusterio/issues |
|
|
+------------------------------------------------------------+
|
|
${err.stack}`
|
|
);
|
|
}
|
|
});
|
|
}
|