mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
"safe" suggested the data being written survives a crash, it only guarantees the old file is either kept or fully replaced. The new name says what it does. No alias, plugin API breaks are allowed in minor versions and the 2.1 migration guide lists the rename. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
873 lines
26 KiB
JavaScript
Executable File
873 lines
26 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import checkbox from "@inquirer/checkbox";
|
|
import confirm from "@inquirer/confirm";
|
|
import input from "@inquirer/input";
|
|
import select from "@inquirer/select";
|
|
import child_process from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import readline from "node:readline";
|
|
import stream from "node:stream";
|
|
import util from "node:util";
|
|
import yargs from "yargs";
|
|
|
|
import { levels, logger, setLogLevel } from "./logging.js";
|
|
import { copyPluginTemplates } from "./template.js";
|
|
let dev = false;
|
|
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
|
import { escapeArg } from "./escape_arg.js";
|
|
|
|
// 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, nodejs/node#30701 and nodejs/node#31762
|
|
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
|
if (process.platform === "win32") {
|
|
const close = readline.Interface.prototype.close;
|
|
readline.Interface.prototype.close = function() {
|
|
this.terminal = false;
|
|
close.call(this);
|
|
};
|
|
}
|
|
|
|
const availablePlugins = [
|
|
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
|
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
|
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
|
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
|
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
|
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
|
];
|
|
|
|
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 atomicOverwriteFile(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 command = [cmd, ...args.map(escapeArg)].join(" ");
|
|
logger.verbose(`executing ${command}`);
|
|
return new Promise((resolve, reject) => {
|
|
let child = child_process.execFile(
|
|
command,
|
|
{
|
|
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(import.meta.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(import.meta.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(import.meta.dirname, "..", "ctl"), ...args]);
|
|
}
|
|
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
|
}
|
|
|
|
// Same characters as the controller refuses in host names.
|
|
const hostNameControlCharacters = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;
|
|
|
|
function validateHostName(value) {
|
|
if (!value || !value.trim()) {
|
|
return "Host name may not be empty";
|
|
}
|
|
if (value.trim().length > 100) {
|
|
return "Host name may be at most 100 characters";
|
|
}
|
|
if (hostNameControlCharacters.test(value)) {
|
|
return "Host name may not contain control characters";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function validateControllerUrl(value) {
|
|
let url;
|
|
try {
|
|
url = new URL(value);
|
|
} catch (err) {
|
|
return "Controller URL must be a URL like https://example.com/";
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol)) {
|
|
return "Controller URL must start with http:// or https://";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The host requires the controller URL to end with a slash.
|
|
function normaliseControllerUrl(value) {
|
|
const url = new URL(value);
|
|
if (!url.pathname.endsWith("/")) {
|
|
url.pathname += "/";
|
|
}
|
|
return url.href;
|
|
}
|
|
|
|
// Remote updates, plugin updates and plugin installs are allowed or denied together.
|
|
async function setRemoteUpdates(exec, component, allow) {
|
|
for (let field of ["allow_remote_updates", "allow_plugin_updates", "allow_plugin_install"]) {
|
|
await exec(["config", "set", `${component}.${field}`, allow]);
|
|
}
|
|
}
|
|
|
|
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 atomicOverwriteFile(destination, JSON.stringify(
|
|
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
|
), { mode: 0o600 });
|
|
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 atomicOverwriteFile(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 atomicOverwriteFile(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);
|
|
}
|
|
}
|
|
atomicOverwriteFile(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}`);
|
|
}
|
|
|
|
// Controller, host and ctl discover installed plugins when loading their configuration.
|
|
}
|
|
|
|
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 atomicOverwriteFile(
|
|
"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 atomicOverwriteFile(
|
|
"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 atomicOverwriteFile(
|
|
"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 atomicOverwriteFile(
|
|
"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 atomicOverwriteFile(
|
|
"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 atomicOverwriteFile(
|
|
"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 promptMissingArgs(args) {
|
|
let answers = {};
|
|
if (args.mode) { answers.mode = args.mode; }
|
|
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
|
answers.mode ??= await select({
|
|
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" },
|
|
],
|
|
});
|
|
|
|
if (["standalone", "controller"].includes(answers.mode)) {
|
|
if (args.admin) { answers.admin = args.admin; }
|
|
answers.admin ??= await input({
|
|
message: "Admin account name",
|
|
validate: value => {
|
|
if (!value) {
|
|
return "May not be empty";
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
if (args.httpPort) { answers.httpPort = args.httpPort; }
|
|
answers.httpPort ??= await input({
|
|
message: "HTTP port to listen on",
|
|
default: "8080",
|
|
validate: value => {
|
|
if (!value) {
|
|
return "May not be empty";
|
|
}
|
|
if (!Number.isInteger(Number(value))) {
|
|
return "Must be a number";
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
if (answers.mode === "standalone") {
|
|
answers.hostName = args.hostName ?? "local";
|
|
const result = validateHostName(answers.hostName);
|
|
if (result !== true) {
|
|
throw new InstallError(result);
|
|
}
|
|
}
|
|
|
|
if (answers.mode === "host") {
|
|
if (args.hostName) { answers.hostName = args.hostName; }
|
|
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
|
answers.hostName ??= await input({
|
|
message: "Name of host",
|
|
default: os.hostname(),
|
|
validate: validateHostName,
|
|
});
|
|
answers.controllerUrl ??= await input({
|
|
message: "Controller URL",
|
|
validate: validateControllerUrl,
|
|
});
|
|
|
|
// The prompts do not validate answers given as arguments
|
|
for (let result of [validateHostName(answers.hostName), validateControllerUrl(answers.controllerUrl)]) {
|
|
if (result !== true) {
|
|
throw new InstallError(result);
|
|
}
|
|
}
|
|
answers.controllerUrl = normaliseControllerUrl(answers.controllerUrl);
|
|
}
|
|
|
|
if (answers.mode === "ctl") {
|
|
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
|
|
answers.controllerUrl ??= await input({ message: "Controller URL" });
|
|
|
|
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
|
|
answers.controllerToken ??= await input({ message: "Controller authentication Token" });
|
|
}
|
|
|
|
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.publicAddress ??= await input({
|
|
message: "Public DNS/IP address of this server",
|
|
default: myIp,
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
answers.factorioDir ??= await select({
|
|
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 },
|
|
],
|
|
});
|
|
|
|
if (answers.factorioDir === null) {
|
|
answers.factorioDir = await input({ message: "Path to Factorio installation" });
|
|
} 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.remoteNpm ??= await confirm({
|
|
message: "Allow remote updates and plugin installs from the web interface?",
|
|
default: true,
|
|
});
|
|
}
|
|
|
|
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.pluginTemplate ??= await checkbox({
|
|
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" },
|
|
],
|
|
});
|
|
answers.pluginName ??= await input({
|
|
message: "Name of your new plugin",
|
|
default: path.basename(path.resolve()),
|
|
});
|
|
}
|
|
|
|
if (dev) {
|
|
answers.plugins = [];
|
|
} else if (args.plugins) {
|
|
answers.plugins = args.plugins;
|
|
}
|
|
answers.plugins ??= await checkbox({
|
|
message: "Plugins to install",
|
|
choices: availablePlugins,
|
|
pageSize: 20,
|
|
});
|
|
|
|
return answers;
|
|
}
|
|
|
|
async function main() {
|
|
let yarg = yargs(process.argv.slice(2))
|
|
.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("host-name", {
|
|
nargs: 1, describe: "Host name [standalone/host]", type: "string",
|
|
})
|
|
.option("controller-url", {
|
|
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
|
|
})
|
|
.option("controller-token", {
|
|
nargs: 1, describe: "Controller authentication token [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, type: "boolean", description:
|
|
"Allow remote updates and plugin installs from the web interface [standalone/controller/host]",
|
|
})
|
|
.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") {
|
|
yarg = yarg
|
|
.option("allow-install-as-root", {
|
|
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
|
})
|
|
;
|
|
}
|
|
|
|
const args = yarg.parse();
|
|
|
|
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 promptMissingArgs(args);
|
|
logger.verbose(JSON.stringify(answers));
|
|
|
|
if (answers.pluginTemplate) {
|
|
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
|
|
return;
|
|
}
|
|
|
|
if (!dev) {
|
|
await installClusterio(answers.mode, answers.plugins);
|
|
}
|
|
|
|
let adminToken = null;
|
|
if (["standalone", "controller"].includes(answers.mode)) {
|
|
logger.info("Setting up controller");
|
|
await execController(["config", "create"]);
|
|
await execController(["bootstrap", "create-admin", answers.admin]);
|
|
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
|
await setRemoteUpdates(execController, "controller", answers.remoteNpm);
|
|
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
|
adminToken = result.stdout.split("\n").slice(-2)[0];
|
|
}
|
|
|
|
if (["standalone", "host"].includes(answers.mode)) {
|
|
logger.info("Setting up host");
|
|
await execHost(["config", "create"]);
|
|
}
|
|
|
|
if (answers.mode === "standalone") {
|
|
// Default to localhost on correct port for host in standalone mode
|
|
answers.controllerUrl = `http://localhost:${answers.httpPort}/`;
|
|
}
|
|
|
|
if (["standalone", "host"].includes(answers.mode)) {
|
|
// The host requests to join the cluster when it is started.
|
|
await execHost(["config", "set", "host.name", answers.hostName]);
|
|
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
|
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
|
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
|
await setRemoteUpdates(execHost, "host", 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(["config", "create"]);
|
|
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
|
|
await execCtl(["config", "set", "ctl.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 (import.meta.main) {
|
|
main().catch(err => {
|
|
process.exitCode = 1;
|
|
if (err.name === "ExitPromptError") {
|
|
// Ctrl+C at a prompt
|
|
process.exitCode = 130;
|
|
} else 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}`
|
|
);
|
|
}
|
|
});
|
|
}
|