From 246ce73d942907ffbcdc5b251cfd415b54c23ddd Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 22 Jun 2022 22:33:54 +0200 Subject: [PATCH] Break commands out of ctl.js --- packages/ctl/ctl.js | 1144 +-------------------------------- packages/ctl/src/commands.js | 1155 ++++++++++++++++++++++++++++++++++ 2 files changed, 1157 insertions(+), 1142 deletions(-) create mode 100644 packages/ctl/src/commands.js diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 41454c3d..41325876 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -8,16 +8,7 @@ const fs = require("fs-extra"); const yargs = require("yargs"); const version = require("./package").version; -const asTable = require("as-table").configure({ delimiter: " | " }); -const events = require("events"); -const path = require("path"); -const winston = require("winston"); const setBlocking = require("set-blocking"); -const phin = require("phin"); -const os = require("os"); -const child_process = require("child_process"); -const stream = require("stream"); -const util = require("util"); const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); @@ -28,1115 +19,8 @@ const libCommand = require("@clusterio/lib/command"); const libSharedCommands = require("@clusterio/lib/shared_commands"); const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); -const libHelpers = require("@clusterio/lib/helpers"); -const libFactorio = require("@clusterio/lib/factorio"); -const libFileOps = require("@clusterio/lib/file_ops"); -const finished = util.promisify(stream.finished); - - -function print(...content) { - // eslint-disable-next-line no-console - console.log(...content); -} - -async function getEditor(argsEditor) { - // eslint-disable-next-line - return argsEditor || process.env.EDITOR || process.env.VISUAL || -1 - // needed for the process.env statements to not be flagged by eslint - // priority for editors is CLI argument > env.EDITOR > env.VISUAL -} - -async function configToKeyVal(data) { - let final = {}; - let splitData = data.split(/\r?\n/); - // split on newlines - let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a); - // the last filter removes empty elements left by the first. Not done on one line due to readability. - for (let index in filtered) { - if (index in filtered) { - filtered[index] = filtered[index].split("="); - let finalIndex = filtered[index][0].trim(); - // split on the = we added earlier, giving us both value and key - let part = ""; - try { - part = filtered[index][1].trim(); - // it's a string if we can read it - } catch (err) { - // if we can't read it, it's a empty field and therefor null - part = ""; - } - final[finalIndex] = part; - } - } - return final; -} - -async function serializedConfigToString(serializedConfig, configGroup, disallowedList) { - let allConfigElements = ""; - for (let group of serializedConfig.groups) { - for (let [name, value] of Object.entries(group.fields)) { - if (`${group.name}.${name}` in disallowedList) { - continue; - } - let desc = ""; - try { - desc += configGroup.groups.get(group.name)._definitions.get(name).description; - } catch (err) { - desc += "No description found"; - } - // split onto two lines for readability and es-lint - if (String(value) === "null") { - value = ""; - } - allConfigElements += `${group.name}.${name} = ${value}\n\n`; - } - } - return allConfigElements; -} - -const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" }); -const masterConfigCommands = new libCommand.CommandTree({ - name: "config", alias: ["c"], description: "master config management", -}); -masterConfigCommands.add(new libCommand.Command({ - definition: ["list", "List master configuration"], - handler: async function(args, control) { - let response = await libLink.messages.getMasterConfig.send(control); - - for (let group of response.serialized_config.groups) { - for (let [name, value] of Object.entries(group.fields)) { - print(`${group.name}.${name} ${JSON.stringify(value)}`); - } - } - }, -})); - -masterConfigCommands.add(new libCommand.Command({ - definition: ["set [value]", "Set field in master config", (yargs) => { - yargs.positional("field", { describe: "Field to set", type: "string" }); - yargs.positional("value", { describe: "Value to set", type: "string" }); - yargs.options({ - "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, - }); - }], - handler: async function(args, control) { - if (args.stdin) { - args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } else if (args.value === undefined) { - args.value = ""; - } - await libLink.messages.setMasterConfigField.send(control, { - field: args.field, - value: args.value, - }); - }, -})); - -masterConfigCommands.add(new libCommand.Command({ - definition: ["set-prop [value]", "Set property of field in master config", (yargs) => { - yargs.positional("field", { describe: "Field to set", type: "string" }); - yargs.positional("prop", { describe: "Property to set", type: "string" }); - yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); - yargs.options({ - "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, - }); - }], - handler: async function(args, control) { - if (args.stdin) { - args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } - let request = { - field: args.field, - prop: args.prop, - }; - try { - if (args.value !== undefined) { - request.value = JSON.parse(args.value); - } - } catch (err) { - // See note for the instance version of set-prop - if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { - throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); - } - request.value = args.value; - } - await libLink.messages.setMasterConfigProp.send(control, request); - }, -})); -masterConfigCommands.add(new libCommand.Command({ - definition: ["edit [editor]", "Edit master configuration", (yargs) => { - yargs.positional("editor", { - describe: "Editor to use", - type: "string", - default: "", - }); - }], - handler: async function(args, control) { - let response = await libLink.messages.getMasterConfig.send(control); - let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); - let editor = await getEditor(args.editor); - if (editor === -1) { - throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars - Try "ctl master config edit "`); - } - let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {}); - await fs.writeFile(tmpFile, allConfigElements, (err) => { - if (err) { - throw err; - } - }); - let editorSpawn = child_process.spawn(editor, [tmpFile], { - stdio: "inherit", - detached: false, - }); - editorSpawn.on("data", (data) => { - process.stdout.pipe(data); - }); - let doneEmitter = new events.EventEmitter(); - editorSpawn.on("exit", async (exit) => { - const data = await fs.readFile(tmpFile, "utf8"); - const final = await configToKeyVal(data); - for (let index in final) { - if (index in final) { - try { - await libLink.messages.setMasterConfigField.send(control, { - field: index, - value: final[index], - }); - } catch (err) { - // eslint-disable-next-line - print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); - print(err); - // If the string is empty, it's better to just print "" instead of nothing - } - } - } - doneEmitter.emit("dot_on_done"); - }); - await events.once(doneEmitter, "dot_on_done"); - await fs.unlink(tmpFile, (err) => { - if (err) { - print("err: temporary file", tmpFile, "could not be deleted."); - print("This is not fatal, but they may build up over time if the issue persists."); - } - }); - }, -})); - -masterCommands.add(masterConfigCommands); - - -const masterPluginCommands = new libCommand.CommandTree({ - name: "plugin", alias: ["p"], description: "master plugin inspection", -}); -masterPluginCommands.add(new libCommand.Command({ - definition: ["list", "List plugins on master"], - handler: async function(args, control) { - let url = new URL(control.config.get("control.master_url")); - url.pathname += "api/plugins"; - let response = await phin({ - url, - parse: "json", - core: { ca: control.tlsCa }, - }); - print(asTable(response.body)); - }, -})); -masterCommands.add(masterPluginCommands); - - -const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); -slaveCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List slaves connected to the master"], - handler: async function(args, control) { - let response = await libLink.messages.listSlaves.send(control); - print(asTable(response.list)); - }, -})); - -slaveCommands.add(new libCommand.Command({ - definition: ["generate-token", "Generate token for a slave", (yargs) => { - yargs.option("id", { type: "number", nargs: 1, describe: "Slave id" }); - }], - handler: async function(args, control) { - let slaveId = typeof args.id === "number" ? args.id : null; - let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId }); - print(response.token); - }, -})); - -slaveCommands.add(new libCommand.Command({ - definition: ["create-config", "Create slave config", (yargs) => { - yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null }); - yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null }); - yargs.option("generate-token", { - type: "boolean", nargs: 0, describe: "Generate authentication token", default: false, - }); - yargs.option("output", { - type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json", - }); - }], - handler: async function(args, control) { - let response = await libLink.messages.createSlaveConfig.send(control, { - id: args.id, name: args.name, generate_token: args.generateToken, - }); - - let content = JSON.stringify(response.serialized_config, null, 4); - if (args.output === "-") { - print(content); - } else { - logger.info(`Writing ${args.output}`); - try { - await fs.outputFile(args.output, content, { flag: "wx" }); - } catch (err) { - if (err.code === "EEXIST") { - throw new libErrors.CommandError(`File ${args.output} already exists`); - } - throw err; - } - } - }, -})); - - -const instanceCommands = new libCommand.CommandTree({ - name: "instance", alias: ["i"], description: "Instance management", -}); -instanceCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List instances known to the master"], - handler: async function(args, control) { - let response = await libLink.messages.listInstances.send(control); - print(asTable(response.list)); - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["create ", "Create an instance", (yargs) => { - // XXX TODO: set any specific options? - yargs.positional("name", { describe: "Instance name", type: "string" }); - yargs.options({ - "id": { type: "number", nargs: 1, describe: "Instance id" }, - }); - }], - handler: async function(args, control) { - let instanceConfig = new libConfig.InstanceConfig("control"); - await instanceConfig.init(); - if (args.id) { - instanceConfig.set("instance.id", args.id); - } - instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize("master"); - await libLink.messages.createInstance.send(control, { serialized_config }); - }, -})); - -const instanceConfigCommands = new libCommand.CommandTree({ - name: "config", alias: ["c"], description: "Instance config management", -}); -instanceConfigCommands.add(new libCommand.Command({ - definition: ["list ", "List configuration for an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to list config for", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); - - for (let group of response.serialized_config.groups) { - for (let [name, value] of Object.entries(group.fields)) { - print(`${group.name}.${name} ${JSON.stringify(value)}`); - } - } - }, -})); - -instanceConfigCommands.add(new libCommand.Command({ - definition: ["set [value]", "Set field in instance config", (yargs) => { - yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); - yargs.positional("field", { describe: "Field to set", type: "string" }); - yargs.positional("value", { describe: "Value to set", type: "string" }); - yargs.options({ - "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - if (args.stdin) { - args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } else if (args.value === undefined) { - args.value = ""; - } - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: args.field, - value: args.value, - }); - }, -})); - -instanceConfigCommands.add(new libCommand.Command({ - definition: ["set-prop [value]", "Set property of field in instance config", (yargs) => { - yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); - yargs.positional("field", { describe: "Field to set", type: "string" }); - yargs.positional("prop", { describe: "Property to set", type: "string" }); - yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); - yargs.options({ - "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - if (args.stdin) { - args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } - let request = { - instance_id: instanceId, - field: args.field, - prop: args.prop, - }; - try { - if (args.value !== undefined) { - request.value = JSON.parse(args.value); - } - } catch (err) { - // If this is from stdin or looks like an array, object or string - // literal throw the parse error, otherwise assume this is a string. - // The resoning behind this is that correctly quoting the string - // with the all the layers of quote removal at play is difficult. - // See the following table for how to pass "That's a \" quote" in - // different environments: - // cmd : """""That's a \\"" quote""""" - // cmd + npx : """""""""""That's a \\\\"""" quote""""""""""" - // PowerShell : '"""""That''s a \\"" quote"""""' - // PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""' - // bash : '""That'\''s a \" quote""' - // bash + npx : '""That'\''s a \" quote""' - // bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'" - if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { - throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); - } - request.value = args.value; - } - await libLink.messages.setInstanceConfigProp.send(control, request); - }, -})); - -instanceConfigCommands.add(new libCommand.Command({ - definition: ["edit [editor]", "Edit instance configuration", (yargs) => { - yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); - yargs.positional("editor", { - describe: "Editor to use", - type: "string", - default: "", - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); - let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); - let editor = await getEditor(args.editor); - if (editor === -1) { - throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars - Try "ctl master config edit "`); - } - let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0}; - let allConfigElements = await serializedConfigToString( - response.serialized_config, - libConfig.InstanceConfig, - disallowedList - ); - await fs.writeFile(tmpFile, allConfigElements, (err) => { - if (err) { - throw err; - } - }); - let editorSpawn = child_process.spawn(editor, [tmpFile], { - stdio: "inherit", - detached: false, - }); - editorSpawn.on("data", (data) => { - process.stdout.pipe(data); - }); - let doneEmitter = new events.EventEmitter(); - editorSpawn.on("exit", async (exit) => { - const data = await fs.readFile(tmpFile, "utf8"); - const final = await configToKeyVal(data); - for (let index in final) { - if (index in final) { - try { - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: index, - value: final[index], - }); - } catch (err) { - // eslint-disable-next-line - print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); - // If the string is empty, it's better to just print "" instead of nothing - print("This message shouldn't normally appear; if the below message does not indicate it"); - print("was a user mistake, please report it to the clustorio devs."); - // added this because it could be a missed entry in disallowedList - // i've added all the vanilla clustorio configs, but there may be - // some modded ones that need to be added. - print(err); - } - } - } - doneEmitter.emit("dot_on_done"); - }); - await events.once(doneEmitter, "dot_on_done"); - await fs.unlink(tmpFile, (err) => { - if (err) { - print("err: temporary file", tmpFile, "could not be deleted."); - print("This is not fatal, but they may build up over time if the issue persists."); - } - }); - }, -})); -instanceCommands.add(instanceConfigCommands); - -instanceCommands.add(new libCommand.Command({ - definition: ["assign [slave]", "Assign instance to a slave", (yargs) => { - yargs.positional("instance", { describe: "Instance to assign", type: "string" }); - yargs.positional("slave", { describe: "Slave to assign to or unassign if none", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let slaveId = args.slave ? await libCommand.resolveSlave(control, args.slave) : null; - await libLink.messages.assignInstanceCommand.send(control, { - instance_id: instanceId, - slave_id: slaveId, - }); - }, -})); - -async function loadMapSettings(args) { - let seed = args.seed !== undefined ? args.seed : null; - let mapGenSettings = null; - let mapSettings = null; - if (args.mapExchangeString) { - let parsed = libFactorio.readMapExchangeString(args.mapExchangeString); - mapGenSettings = parsed.map_gen_settings; - mapSettings = parsed.map_settings; - } - if (args.mapGenSettings) { - mapGenSettings = JSON.parse(await fs.readFile(args.mapGenSettings)); - } - if (args.mapSettings) { - mapSettings = JSON.parse(await fs.readFile(args.mapSettings)); - } - - return { - seed, - mapGenSettings, - mapSettings, - }; -} - -const instanceSaveCommands = new libCommand.CommandTree({ - name: "save", alias: ["s"], description: "Instance save management", -}); -instanceSaveCommands.add(new libCommand.Command({ - definition: ["list ", "list saves on an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); - for (let entry of response.list) { - entry.mtime = new Date(entry.mtime_ms).toLocaleString(); - delete entry.mtime_ms; - } - print(asTable(response.list)); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["create [name]", "Create a new save on an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to create on", type: "string" }); - yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" }); - yargs.options({ - "seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" }, - "map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" }, - "map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" }, - "map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" }, - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.createSave.send(control, { - instance_id: instanceId, - name: args.name, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["rename ", "Rename a save on an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to rename save on", type: "string" }); - yargs.positional("old-name", { describe: "Old name of save.", type: "string" }); - yargs.positional("new-name", { describe: "New name of save.", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.renameSave.send(control, { - instance_id: instanceId, - old_name: args.oldName, - new_name: args.newName, - }); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["copy ", "Copy a save on an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to copy save on", type: "string" }); - yargs.positional("source", { describe: "Save to copy.", type: "string" }); - yargs.positional("destination", { describe: "Name of copy.", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.copySave.send(control, { - instance_id: instanceId, - source: args.source, - destination: args.destination, - }); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["upload ", "Upload a save to an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to upload to", type: "string" }); - yargs.positional("filepath", { describe: "Path to save to upload", type: "string" }); - yargs.options({ - "name": { describe: "Name to give save on server", nargs: 1, type: "string" }, - }); - }], - handler: async function(args, control) { - let filename = args.name || path.basename(args.filepath); - if (!filename.endsWith(".zip")) { - throw new libErrors.CommandError("Save name must end with .zip"); - } - // phin doesn't support streaming requests :( - let content = await fs.readFile(args.filepath); - - let instanceId = await libCommand.resolveInstance(control, args.instance); - let url = new URL(control.config.get("control.master_url")); - url.pathname += "api/upload-save"; - url.searchParams.append("instance_id", instanceId); - url.searchParams.append("filename", filename); - - let result = await phin({ - url, method: "POST", - headers: { - "X-Access-Token": control.config.get("control.master_token"), - "Content-Type": "application/zip", - }, - core: { ca: control.tlsCa }, - data: content, - parse: "json", - }); - - for (let error of result.body.errors || []) { - logger.error(error); - } - - for (let requestError of result.body.request_errors || []) { - logger.error(requestError); - } - - if (result.body.saves && result.body.saves.length) { - logger.info(`Successfully uploaded as ${result.body.saves[0]}`); - } - - if ((result.body.errors || []).length || (result.body.request_errors || []).length) { - throw new libErrors.CommandError("Uploading save failed"); - } - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: [ - "transfer [target-save]", - "Transfer a save between instances", - (yargs) => { - yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" }); - yargs.positional("source-save", { describe: "Save to transfer.", type: "string" }); - yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" }); - yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" }); - yargs.options({ - "copy": { describe: "Copy instead of moving the save", type: "boolean", default: false }, - }); - }, - ], - handler: async function(args, control) { - let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); - let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); - let result = await libLink.messages.transferSave.send(control, { - instance_id: sourceInstanceId, - source_save: args.sourceSave, - target_instance_id: targetInstanceId, - target_save: args.targetSave || args.sourceSave, - copy: args.copy, - }); - print(`Transferred as ${result.save} to ${args.targetInstance}.`); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["download ", "Download a save from an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to download save from", type: "string" }); - yargs.positional("save", { describe: "Save to download", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - let result = await libLink.messages.downloadSave.send(control, { - instance_id: instanceId, - save: args.save, - }); - - let url = new URL(control.config.get("control.master_url")); - url.pathname += `api/stream/${result.stream_id}`; - let response = await phin({ - url, method: "GET", - core: { ca: control.tlsCa }, - stream: true, - }); - - let writeStream; - let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip"); - while (true) { - try { - writeStream = fs.createWriteStream(tempFilename, { flags: "wx" }); - await events.once(writeStream, "open"); - break; - } catch (err) { - if (err.code === "EEXIST") { - tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip"); - } else { - throw err; - } - } - } - response.pipe(writeStream); - await finished(writeStream); - - let filename = await libFileOps.findUnusedName(".", args.save, ".zip"); - await fs.rename(tempFilename, filename); - - logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`); - }, -})); - -instanceSaveCommands.add(new libCommand.Command({ - definition: ["delete ", "Delete a save from an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to delete save from", type: "string" }); - yargs.positional("save", { describe: "Save to delete", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.deleteSave.send(control, { - instance_id: instanceId, - save: args.save, - }); - }, -})); -instanceCommands.add(instanceSaveCommands); - -instanceCommands.add(new libCommand.Command({ - definition: ["export-data ", "Export item icons and locale from instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to export from", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.exportData.send(control, { - instance_id: instanceId, - }); - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["start ", "Start instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to start", type: "string" }); - yargs.options({ - "save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" }, - "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.startInstance.send(control, { - instance_id: instanceId, - save: args.save || null, - }); - control.keepOpen = args.keepOpen; - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["load-scenario ", "Start instance by loading a scenario", (yargs) => { - yargs.positional("instance", { describe: "Instance to start", type: "string" }); - yargs.positional("scenario", { describe: "Scenario to load", type: "string" }); - yargs.options({ - "seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" }, - "map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" }, - "map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" }, - "map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" }, - "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await libLink.messages.loadScenario.send(control, { - instance_id: instanceId, - scenario: args.scenario, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); - control.keepOpen = args.keepOpen; - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["stop ", "Stop instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to stop", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.stopInstance.send(control, { - instance_id: instanceId, - }); - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["kill ", "Kill instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to kill", type: "string" }); - }], - handler: async function(args, control) { - let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.killInstance.send(control, { - instance_id: instanceId, - }); - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["delete ", "Delete instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to delete", type: "string" }); - }], - handler: async function(args, control) { - await libLink.messages.deleteInstance.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - }); - }, -})); - -instanceCommands.add(new libCommand.Command({ - definition: ["send-rcon ", "Send RCON command", (yargs) => { - yargs.positional("instance", { describe: "Instance to send to", type: "string" }); - yargs.positional("command", { describe: "command to send", type: "string" }); - }], - handler: async function(args, control) { - let response = await libLink.messages.sendRcon.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - command: args.command, - }); - - // Factorio includes a newline in it's response output. - process.stdout.write(response.result); - }, -})); - -const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" }); -permissionCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List permissions in the cluster"], - handler: async function(args, control) { - let response = await libLink.messages.listPermissions.send(control); - print(asTable(response.list)); - }, -})); - - -const roleCommands = new libCommand.CommandTree({ name: "role", description: "Role management" }); -roleCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List roles in the cluster"], - handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); - print(asTable(response.list)); - }, -})); - -roleCommands.add(new libCommand.Command({ - definition: ["create ", "Create a new role", (yargs) => { - yargs.positional("name", { describe: "Name of role to create", type: "string" }); - yargs.options({ - "description": { describe: "Description for role", nargs: 1, type: "string", default: "" }, - "permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] }, - }); - }], - handler: async function(args, control) { - let response = await libLink.messages.createRole.send(control, { - name: args.name, - description: args.description, - permissions: args.permissions, - }); - logger.info(`Created role ID ${response.id}`); - }, -})); - -roleCommands.add(new libCommand.Command({ - definition: ["edit ", "Edit existing role", (yargs) => { - yargs.positional("role", { describe: "Role to edit", type: "string" }); - yargs.options({ - "name": { describe: "New name for role", nargs: 1, type: "string" }, - "description": { describe: "New description for role", nargs: 1, type: "string" }, - "set-perms": { describe: "Set permissions for role", array: true, type: "string" }, - "add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" }, - "remove-perms": { - describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms", - }, - "grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" }, - }); - }], - handler: async function(args, control) { - let role = await libCommand.retrieveRole(control, args.role); - - if (args.name !== undefined) { - role.name = args.name; - } - if (args.description !== undefined) { - role.description = args.description; - } - if (args.addPerms) { - role.permissions = role.permissions.concat(args.addPerms); - } - if (args.removePerms) { - let perms = new Set(role.permissions); - for (let perm of args.removePerms) { - perms.delete(perm); - } - role.permissions = [...perms]; - } - if (args.setPerms !== undefined) { - role.permissions = args.setPerms; - } - await libLink.messages.updateRole.send(control, role); - - if (args.grantDefault) { - await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id }); - } - }, -})); - -roleCommands.add(new libCommand.Command({ - definition: ["delete ", "Delete role", (yargs) => { - yargs.positional("role", { describe: "Role to delete", type: "string" }); - }], - handler: async function(args, control) { - let role = await libCommand.retrieveRole(control, args.role); - await libLink.messages.deleteRole.send(control, { id: role.id }); - }, -})); - - -const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" }); -userCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List user in the cluster"], - handler: async function(args, control) { - let response = await libLink.messages.listUsers.send(control); - print(asTable(response.list)); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["create ", "Create a user", (yargs) => { - yargs.positional("name", { describe: "Name of user to create", type: "string" }); - }], - handler: async function(args, control) { - await libLink.messages.createUser.send(control, { name: args.name }); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["set-admin ", "Promote or demote a user to admin", (yargs) => { - yargs.positional("user", { describe: "Name of user set admin status for", type: "string" }); - yargs.options({ - "revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false }, - "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - await libLink.messages.setUserAdmin.send(control, { - name: args.user, create: args.create, admin: !args.revoke, - }); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["set-whitelisted ", "Add or remove user from the whitelist", (yargs) => { - yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" }); - yargs.options({ - "remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false }, - "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - await libLink.messages.setUserWhitelisted.send(control, { - name: args.user, create: args.create, whitelisted: !args.remove, - }); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["set-banned ", "Ban or pardon user from banlist", (yargs) => { - yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" }); - yargs.options({ - "pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false }, - "reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" }, - "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - await libLink.messages.setUserBanned.send(control, { - name: args.user, create: args.create, banned: !args.pardon, reason: args.reason, - }); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["set-roles [roles...]", "Replace user roles", (yargs) => { - yargs.positional("user", { describe: "Name of user to change roles for", type: "string" }); - yargs.positional("roles", { describe: "roles to assign", type: "string" }); - }], - handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); - - let resolvedRoles = []; - for (let roleName of args.roles) { - if (/^-?\d+$/.test(roleName)) { - let roleId = parseInt(roleName, 10); - resolvedRoles.push(roleId); - - } else { - let found = false; - for (let role of response.list) { - if (role.name === roleName) { - resolvedRoles.push(role.id); - found = true; - break; - } - } - - if (!found) { - throw new libErrors.CommandError(`No role named ${roleName}`); - } - } - } - - await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles }); - }, -})); - -userCommands.add(new libCommand.Command({ - definition: ["delete ", "Delete user", (yargs) => { - yargs.positional("user", { describe: "Name of user to delete", type: "string" }); - }], - handler: async function(args, control) { - await libLink.messages.deleteUser.send(control, { name: args.user }); - }, -})); - -const logCommands = new libCommand.CommandTree({ name: "log", description: "Log inspection" }); -logCommands.add(new libCommand.Command({ - definition: ["follow", "follow cluster log", (yargs) => { - yargs.options({ - "all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false }, - "master": { describe: "Follow log of the master server", nargs: 0, type: "boolean", default: false }, - "slave": { describe: "Follow log of given slave", nargs: 1, type: "string", default: null }, - "instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null }, - }); - }], - handler: async function(args, control) { - if (!args.all && !args.master && !args.slave && !args.instance) { - logger.error("At least one of --all, --master, --slave and --instance must be passed"); - process.exitCode = 1; - return; - } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : []; - await control.setLogSubscriptions({ all: args.all, master: args.master, slave_ids, instance_ids }); - control.keepOpen = true; - }, -})); - -logCommands.add(new libCommand.Command({ - definition: ["query", "Query cluster log", (yargs) => { - yargs.options({ - "all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false }, - "master": { describe: "Query log of the master server", nargs: 0, type: "boolean", default: false }, - "slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null }, - "instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null }, - "max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null }, - "limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 }, - "start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args, control) { - if (!args.all && !args.master && !args.slave && !args.instance) { - logger.error("At least one of --all, --master, --slave and --instance must be passed"); - process.exitCode = 1; - return; - } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : []; - let result = await libLink.messages.queryLog.send(control, { - all: args.all, - master: args.master, - slave_ids, - instance_ids, - max_level: args.maxLevel, - limit: args.limit, - order: args.start ? "asc" : "desc", - }); - - if (!args.start) { - result.log.reverse(); - } - - let stdoutLogger = winston.createLogger({ - level: "verbose", - levels, - format: new libLoggingUtils.TerminalFormat({ showTimestamp: true }), - transports: [ - new ConsoleTransport({ errorLevels: [], warnLevels: [] }), - ], - }); - for (let info of result.log) { - stdoutLogger.log(info); - } - }, -})); - -const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" }); -debugCommands.add(new libCommand.Command({ - definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }], - handler: async function(args, control) { - await libLink.messages.debugDumpWs.send(control); - control.keepOpen = true; - }, -})); +const commands = require("./src/commands"); /** @@ -1257,30 +141,6 @@ async function loadPlugins(pluginList) { return controlPlugins; } -async function registerCommands(controlPlugins, yargs) { - const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); - rootCommands.add(masterCommands); - rootCommands.add(slaveCommands); - rootCommands.add(instanceCommands); - rootCommands.add(permissionCommands); - rootCommands.add(roleCommands); - rootCommands.add(userCommands); - rootCommands.add(logCommands); - rootCommands.add(debugCommands); - - for (let controlPlugin of controlPlugins.values()) { - await controlPlugin.addCommands(rootCommands); - } - - for (let [name, command] of rootCommands.subCommands) { - if (name === command.name) { - command.register(yargs); - } - } - - return rootCommands; -} - async function startControl() { yargs .scriptName("clusterioctl") @@ -1341,7 +201,7 @@ async function startControl() { let controlPlugins = await loadPlugins(pluginList); // Add all cluster management commands including ones from plugins - let rootCommands = await registerCommands(controlPlugins, yargs); + let rootCommands = await commands.registerCommands(controlPlugins, yargs); // Reparse after commands have been added with help and strict checking. args = yargs diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js new file mode 100644 index 00000000..cfa79189 --- /dev/null +++ b/packages/ctl/src/commands.js @@ -0,0 +1,1155 @@ +"use strict"; +const fs = require("fs-extra"); +const asTable = require("as-table").configure({ delimiter: " | " }); +const events = require("events"); +const path = require("path"); +const winston = require("winston"); +const phin = require("phin"); +const os = require("os"); +const child_process = require("child_process"); +const stream = require("stream"); +const util = require("util"); + +const libLink = require("@clusterio/lib/link"); +const libErrors = require("@clusterio/lib/errors"); +const libConfig = require("@clusterio/lib/config"); +const libCommand = require("@clusterio/lib/command"); +const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging"); +const libLoggingUtils = require("@clusterio/lib/logging_utils"); +const libHelpers = require("@clusterio/lib/helpers"); +const libFactorio = require("@clusterio/lib/factorio"); +const libFileOps = require("@clusterio/lib/file_ops"); + +const finished = util.promisify(stream.finished); + + +function print(...content) { + // eslint-disable-next-line no-console + console.log(...content); +} + +async function getEditor(argsEditor) { + // eslint-disable-next-line + return argsEditor || process.env.EDITOR || process.env.VISUAL || -1 + // needed for the process.env statements to not be flagged by eslint + // priority for editors is CLI argument > env.EDITOR > env.VISUAL +} + +async function configToKeyVal(data) { + let final = {}; + let splitData = data.split(/\r?\n/); + // split on newlines + let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a); + // the last filter removes empty elements left by the first. Not done on one line due to readability. + for (let index in filtered) { + if (index in filtered) { + filtered[index] = filtered[index].split("="); + let finalIndex = filtered[index][0].trim(); + // split on the = we added earlier, giving us both value and key + let part = ""; + try { + part = filtered[index][1].trim(); + // it's a string if we can read it + } catch (err) { + // if we can't read it, it's a empty field and therefor null + part = ""; + } + final[finalIndex] = part; + } + } + return final; +} + +async function serializedConfigToString(serializedConfig, configGroup, disallowedList) { + let allConfigElements = ""; + for (let group of serializedConfig.groups) { + for (let [name, value] of Object.entries(group.fields)) { + if (`${group.name}.${name}` in disallowedList) { + continue; + } + let desc = ""; + try { + desc += configGroup.groups.get(group.name)._definitions.get(name).description; + } catch (err) { + desc += "No description found"; + } + // split onto two lines for readability and es-lint + if (String(value) === "null") { + value = ""; + } + allConfigElements += `${group.name}.${name} = ${value}\n\n`; + } + } + return allConfigElements; +} + +const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" }); +const masterConfigCommands = new libCommand.CommandTree({ + name: "config", alias: ["c"], description: "master config management", +}); +masterConfigCommands.add(new libCommand.Command({ + definition: ["list", "List master configuration"], + handler: async function(args, control) { + let response = await libLink.messages.getMasterConfig.send(control); + + for (let group of response.serialized_config.groups) { + for (let [name, value] of Object.entries(group.fields)) { + print(`${group.name}.${name} ${JSON.stringify(value)}`); + } + } + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set [value]", "Set field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("value", { describe: "Value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await libLink.messages.setMasterConfigField.send(control, { + field: args.field, + value: args.value, + }); + }, +})); + +masterConfigCommands.add(new libCommand.Command({ + definition: ["set-prop [value]", "Set property of field in master config", (yargs) => { + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("prop", { describe: "Property to set", type: "string" }); + yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let request = { + field: args.field, + prop: args.prop, + }; + try { + if (args.value !== undefined) { + request.value = JSON.parse(args.value); + } + } catch (err) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { + throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + request.value = args.value; + } + await libLink.messages.setMasterConfigProp.send(control, request); + }, +})); +masterConfigCommands.add(new libCommand.Command({ + definition: ["edit [editor]", "Edit master configuration", (yargs) => { + yargs.positional("editor", { + describe: "Editor to use", + type: "string", + default: "", + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.getMasterConfig.send(control); + let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === -1) { + throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl master config edit "`); + } + let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {}); + await fs.writeFile(tmpFile, allConfigElements, (err) => { + if (err) { + throw err; + } + }); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + let doneEmitter = new events.EventEmitter(); + editorSpawn.on("exit", async (exit) => { + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await libLink.messages.setMasterConfigField.send(control, { + field: index, + value: final[index], + }); + } catch (err) { + // eslint-disable-next-line + print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); + print(err); + // If the string is empty, it's better to just print "" instead of nothing + } + } + } + doneEmitter.emit("dot_on_done"); + }); + await events.once(doneEmitter, "dot_on_done"); + await fs.unlink(tmpFile, (err) => { + if (err) { + print("err: temporary file", tmpFile, "could not be deleted."); + print("This is not fatal, but they may build up over time if the issue persists."); + } + }); + }, +})); + +masterCommands.add(masterConfigCommands); + + +const masterPluginCommands = new libCommand.CommandTree({ + name: "plugin", alias: ["p"], description: "master plugin inspection", +}); +masterPluginCommands.add(new libCommand.Command({ + definition: ["list", "List plugins on master"], + handler: async function(args, control) { + let url = new URL(control.config.get("control.master_url")); + url.pathname += "api/plugins"; + let response = await phin({ + url, + parse: "json", + core: { ca: control.tlsCa }, + }); + print(asTable(response.body)); + }, +})); +masterCommands.add(masterPluginCommands); + + +const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" }); +slaveCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List slaves connected to the master"], + handler: async function(args, control) { + let response = await libLink.messages.listSlaves.send(control); + print(asTable(response.list)); + }, +})); + +slaveCommands.add(new libCommand.Command({ + definition: ["generate-token", "Generate token for a slave", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Slave id" }); + }], + handler: async function(args, control) { + let slaveId = typeof args.id === "number" ? args.id : null; + let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId }); + print(response.token); + }, +})); + +slaveCommands.add(new libCommand.Command({ + definition: ["create-config", "Create slave config", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null }); + yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null }); + yargs.option("generate-token", { + type: "boolean", nargs: 0, describe: "Generate authentication token", default: false, + }); + yargs.option("output", { + type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json", + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.createSlaveConfig.send(control, { + id: args.id, name: args.name, generate_token: args.generateToken, + }); + + let content = JSON.stringify(response.serialized_config, null, 4); + if (args.output === "-") { + print(content); + } else { + logger.info(`Writing ${args.output}`); + try { + await fs.outputFile(args.output, content, { flag: "wx" }); + } catch (err) { + if (err.code === "EEXIST") { + throw new libErrors.CommandError(`File ${args.output} already exists`); + } + throw err; + } + } + }, +})); + + +const instanceCommands = new libCommand.CommandTree({ + name: "instance", alias: ["i"], description: "Instance management", +}); +instanceCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List instances known to the master"], + handler: async function(args, control) { + let response = await libLink.messages.listInstances.send(control); + print(asTable(response.list)); + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["create ", "Create an instance", (yargs) => { + // XXX TODO: set any specific options? + yargs.positional("name", { describe: "Instance name", type: "string" }); + yargs.options({ + "id": { type: "number", nargs: 1, describe: "Instance id" }, + }); + }], + handler: async function(args, control) { + let instanceConfig = new libConfig.InstanceConfig("control"); + await instanceConfig.init(); + if (args.id) { + instanceConfig.set("instance.id", args.id); + } + instanceConfig.set("instance.name", args.name); + let serialized_config = instanceConfig.serialize("master"); + await libLink.messages.createInstance.send(control, { serialized_config }); + }, +})); + +const instanceConfigCommands = new libCommand.CommandTree({ + name: "config", alias: ["c"], description: "Instance config management", +}); +instanceConfigCommands.add(new libCommand.Command({ + definition: ["list ", "List configuration for an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list config for", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + + for (let group of response.serialized_config.groups) { + for (let [name, value] of Object.entries(group.fields)) { + print(`${group.name}.${name} ${JSON.stringify(value)}`); + } + } + }, +})); + +instanceConfigCommands.add(new libCommand.Command({ + definition: ["set [value]", "Set field in instance config", (yargs) => { + yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("value", { describe: "Value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await libLink.messages.setInstanceConfigField.send(control, { + instance_id: instanceId, + field: args.field, + value: args.value, + }); + }, +})); + +instanceConfigCommands.add(new libCommand.Command({ + definition: ["set-prop [value]", "Set property of field in instance config", (yargs) => { + yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("prop", { describe: "Property to set", type: "string" }); + yargs.positional("value", { describe: "JSON parsed value to set", type: "string" }); + yargs.options({ + "stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + if (args.stdin) { + args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let request = { + instance_id: instanceId, + field: args.field, + prop: args.prop, + }; + try { + if (args.value !== undefined) { + request.value = JSON.parse(args.value); + } + } catch (err) { + // If this is from stdin or looks like an array, object or string + // literal throw the parse error, otherwise assume this is a string. + // The resoning behind this is that correctly quoting the string + // with the all the layers of quote removal at play is difficult. + // See the following table for how to pass "That's a \" quote" in + // different environments: + // cmd : """""That's a \\"" quote""""" + // cmd + npx : """""""""""That's a \\\\"""" quote""""""""""" + // PowerShell : '"""""That''s a \\"" quote"""""' + // PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""' + // bash : '""That'\''s a \" quote""' + // bash + npx : '""That'\''s a \" quote""' + // bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'" + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { + throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + request.value = args.value; + } + await libLink.messages.setInstanceConfigProp.send(control, request); + }, +})); + +instanceConfigCommands.add(new libCommand.Command({ + definition: ["edit [editor]", "Edit instance configuration", (yargs) => { + yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); + yargs.positional("editor", { + describe: "Editor to use", + type: "string", + default: "", + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === -1) { + throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl master config edit "`); + } + let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0}; + let allConfigElements = await serializedConfigToString( + response.serialized_config, + libConfig.InstanceConfig, + disallowedList + ); + await fs.writeFile(tmpFile, allConfigElements, (err) => { + if (err) { + throw err; + } + }); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + let doneEmitter = new events.EventEmitter(); + editorSpawn.on("exit", async (exit) => { + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await libLink.messages.setInstanceConfigField.send(control, { + instance_id: instanceId, + field: index, + value: final[index], + }); + } catch (err) { + // eslint-disable-next-line + print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); + // If the string is empty, it's better to just print "" instead of nothing + print("This message shouldn't normally appear; if the below message does not indicate it"); + print("was a user mistake, please report it to the clustorio devs."); + // added this because it could be a missed entry in disallowedList + // i've added all the vanilla clustorio configs, but there may be + // some modded ones that need to be added. + print(err); + } + } + } + doneEmitter.emit("dot_on_done"); + }); + await events.once(doneEmitter, "dot_on_done"); + await fs.unlink(tmpFile, (err) => { + if (err) { + print("err: temporary file", tmpFile, "could not be deleted."); + print("This is not fatal, but they may build up over time if the issue persists."); + } + }); + }, +})); +instanceCommands.add(instanceConfigCommands); + +instanceCommands.add(new libCommand.Command({ + definition: ["assign [slave]", "Assign instance to a slave", (yargs) => { + yargs.positional("instance", { describe: "Instance to assign", type: "string" }); + yargs.positional("slave", { describe: "Slave to assign to or unassign if none", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let slaveId = args.slave ? await libCommand.resolveSlave(control, args.slave) : null; + await libLink.messages.assignInstanceCommand.send(control, { + instance_id: instanceId, + slave_id: slaveId, + }); + }, +})); + +async function loadMapSettings(args) { + let seed = args.seed !== undefined ? args.seed : null; + let mapGenSettings = null; + let mapSettings = null; + if (args.mapExchangeString) { + let parsed = libFactorio.readMapExchangeString(args.mapExchangeString); + mapGenSettings = parsed.map_gen_settings; + mapSettings = parsed.map_settings; + } + if (args.mapGenSettings) { + mapGenSettings = JSON.parse(await fs.readFile(args.mapGenSettings)); + } + if (args.mapSettings) { + mapSettings = JSON.parse(await fs.readFile(args.mapSettings)); + } + + return { + seed, + mapGenSettings, + mapSettings, + }; +} + +const instanceSaveCommands = new libCommand.CommandTree({ + name: "save", alias: ["s"], description: "Instance save management", +}); +instanceSaveCommands.add(new libCommand.Command({ + definition: ["list ", "list saves on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); + for (let entry of response.list) { + entry.mtime = new Date(entry.mtime_ms).toLocaleString(); + delete entry.mtime_ms; + } + print(asTable(response.list)); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["create [name]", "Create a new save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to create on", type: "string" }); + yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" }); + yargs.options({ + "seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" }, + "map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" }, + "map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" }, + "map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" }, + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.createSave.send(control, { + instance_id: instanceId, + name: args.name, + seed, + map_gen_settings: mapGenSettings, + map_settings: mapSettings, + }); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["rename ", "Rename a save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to rename save on", type: "string" }); + yargs.positional("old-name", { describe: "Old name of save.", type: "string" }); + yargs.positional("new-name", { describe: "New name of save.", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await libLink.messages.renameSave.send(control, { + instance_id: instanceId, + old_name: args.oldName, + new_name: args.newName, + }); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["copy ", "Copy a save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to copy save on", type: "string" }); + yargs.positional("source", { describe: "Save to copy.", type: "string" }); + yargs.positional("destination", { describe: "Name of copy.", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await libLink.messages.copySave.send(control, { + instance_id: instanceId, + source: args.source, + destination: args.destination, + }); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["upload ", "Upload a save to an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to upload to", type: "string" }); + yargs.positional("filepath", { describe: "Path to save to upload", type: "string" }); + yargs.options({ + "name": { describe: "Name to give save on server", nargs: 1, type: "string" }, + }); + }], + handler: async function(args, control) { + let filename = args.name || path.basename(args.filepath); + if (!filename.endsWith(".zip")) { + throw new libErrors.CommandError("Save name must end with .zip"); + } + // phin doesn't support streaming requests :( + let content = await fs.readFile(args.filepath); + + let instanceId = await libCommand.resolveInstance(control, args.instance); + let url = new URL(control.config.get("control.master_url")); + url.pathname += "api/upload-save"; + url.searchParams.append("instance_id", instanceId); + url.searchParams.append("filename", filename); + + let result = await phin({ + url, method: "POST", + headers: { + "X-Access-Token": control.config.get("control.master_token"), + "Content-Type": "application/zip", + }, + core: { ca: control.tlsCa }, + data: content, + parse: "json", + }); + + for (let error of result.body.errors || []) { + logger.error(error); + } + + for (let requestError of result.body.request_errors || []) { + logger.error(requestError); + } + + if (result.body.saves && result.body.saves.length) { + logger.info(`Successfully uploaded as ${result.body.saves[0]}`); + } + + if ((result.body.errors || []).length || (result.body.request_errors || []).length) { + throw new libErrors.CommandError("Uploading save failed"); + } + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: [ + "transfer [target-save]", + "Transfer a save between instances", + (yargs) => { + yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" }); + yargs.positional("source-save", { describe: "Save to transfer.", type: "string" }); + yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" }); + yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" }); + yargs.options({ + "copy": { describe: "Copy instead of moving the save", type: "boolean", default: false }, + }); + }, + ], + handler: async function(args, control) { + let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); + let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); + let result = await libLink.messages.transferSave.send(control, { + instance_id: sourceInstanceId, + source_save: args.sourceSave, + target_instance_id: targetInstanceId, + target_save: args.targetSave || args.sourceSave, + copy: args.copy, + }); + print(`Transferred as ${result.save} to ${args.targetInstance}.`); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["download ", "Download a save from an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to download save from", type: "string" }); + yargs.positional("save", { describe: "Save to download", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + let result = await libLink.messages.downloadSave.send(control, { + instance_id: instanceId, + save: args.save, + }); + + let url = new URL(control.config.get("control.master_url")); + url.pathname += `api/stream/${result.stream_id}`; + let response = await phin({ + url, method: "GET", + core: { ca: control.tlsCa }, + stream: true, + }); + + let writeStream; + let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip"); + while (true) { + try { + writeStream = fs.createWriteStream(tempFilename, { flags: "wx" }); + await events.once(writeStream, "open"); + break; + } catch (err) { + if (err.code === "EEXIST") { + tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip"); + } else { + throw err; + } + } + } + response.pipe(writeStream); + await finished(writeStream); + + let filename = await libFileOps.findUnusedName(".", args.save, ".zip"); + await fs.rename(tempFilename, filename); + + logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete a save from an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to delete save from", type: "string" }); + yargs.positional("save", { describe: "Save to delete", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await libLink.messages.deleteSave.send(control, { + instance_id: instanceId, + save: args.save, + }); + }, +})); +instanceCommands.add(instanceSaveCommands); + +instanceCommands.add(new libCommand.Command({ + definition: ["export-data ", "Export item icons and locale from instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to export from", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.exportData.send(control, { + instance_id: instanceId, + }); + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["start ", "Start instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to start", type: "string" }); + yargs.options({ + "save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.startInstance.send(control, { + instance_id: instanceId, + save: args.save || null, + }); + control.keepOpen = args.keepOpen; + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["load-scenario ", "Start instance by loading a scenario", (yargs) => { + yargs.positional("instance", { describe: "Instance to start", type: "string" }); + yargs.positional("scenario", { describe: "Scenario to load", type: "string" }); + yargs.options({ + "seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" }, + "map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" }, + "map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" }, + "map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); + await libLink.messages.loadScenario.send(control, { + instance_id: instanceId, + scenario: args.scenario, + seed, + map_gen_settings: mapGenSettings, + map_settings: mapSettings, + }); + control.keepOpen = args.keepOpen; + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["stop ", "Stop instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to stop", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.stopInstance.send(control, { + instance_id: instanceId, + }); + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["kill ", "Kill instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to kill", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.killInstance.send(control, { + instance_id: instanceId, + }); + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to delete", type: "string" }); + }], + handler: async function(args, control) { + await libLink.messages.deleteInstance.send(control, { + instance_id: await libCommand.resolveInstance(control, args.instance), + }); + }, +})); + +instanceCommands.add(new libCommand.Command({ + definition: ["send-rcon ", "Send RCON command", (yargs) => { + yargs.positional("instance", { describe: "Instance to send to", type: "string" }); + yargs.positional("command", { describe: "command to send", type: "string" }); + }], + handler: async function(args, control) { + let response = await libLink.messages.sendRcon.send(control, { + instance_id: await libCommand.resolveInstance(control, args.instance), + command: args.command, + }); + + // Factorio includes a newline in it's response output. + process.stdout.write(response.result); + }, +})); + +const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" }); +permissionCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List permissions in the cluster"], + handler: async function(args, control) { + let response = await libLink.messages.listPermissions.send(control); + print(asTable(response.list)); + }, +})); + + +const roleCommands = new libCommand.CommandTree({ name: "role", description: "Role management" }); +roleCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List roles in the cluster"], + handler: async function(args, control) { + let response = await libLink.messages.listRoles.send(control); + print(asTable(response.list)); + }, +})); + +roleCommands.add(new libCommand.Command({ + definition: ["create ", "Create a new role", (yargs) => { + yargs.positional("name", { describe: "Name of role to create", type: "string" }); + yargs.options({ + "description": { describe: "Description for role", nargs: 1, type: "string", default: "" }, + "permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] }, + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.createRole.send(control, { + name: args.name, + description: args.description, + permissions: args.permissions, + }); + logger.info(`Created role ID ${response.id}`); + }, +})); + +roleCommands.add(new libCommand.Command({ + definition: ["edit ", "Edit existing role", (yargs) => { + yargs.positional("role", { describe: "Role to edit", type: "string" }); + yargs.options({ + "name": { describe: "New name for role", nargs: 1, type: "string" }, + "description": { describe: "New description for role", nargs: 1, type: "string" }, + "set-perms": { describe: "Set permissions for role", array: true, type: "string" }, + "add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" }, + "remove-perms": { + describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms", + }, + "grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" }, + }); + }], + handler: async function(args, control) { + let role = await libCommand.retrieveRole(control, args.role); + + if (args.name !== undefined) { + role.name = args.name; + } + if (args.description !== undefined) { + role.description = args.description; + } + if (args.addPerms) { + role.permissions = role.permissions.concat(args.addPerms); + } + if (args.removePerms) { + let perms = new Set(role.permissions); + for (let perm of args.removePerms) { + perms.delete(perm); + } + role.permissions = [...perms]; + } + if (args.setPerms !== undefined) { + role.permissions = args.setPerms; + } + await libLink.messages.updateRole.send(control, role); + + if (args.grantDefault) { + await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id }); + } + }, +})); + +roleCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete role", (yargs) => { + yargs.positional("role", { describe: "Role to delete", type: "string" }); + }], + handler: async function(args, control) { + let role = await libCommand.retrieveRole(control, args.role); + await libLink.messages.deleteRole.send(control, { id: role.id }); + }, +})); + + +const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" }); +userCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List user in the cluster"], + handler: async function(args, control) { + let response = await libLink.messages.listUsers.send(control); + print(asTable(response.list)); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["create ", "Create a user", (yargs) => { + yargs.positional("name", { describe: "Name of user to create", type: "string" }); + }], + handler: async function(args, control) { + await libLink.messages.createUser.send(control, { name: args.name }); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["set-admin ", "Promote or demote a user to admin", (yargs) => { + yargs.positional("user", { describe: "Name of user set admin status for", type: "string" }); + yargs.options({ + "revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await libLink.messages.setUserAdmin.send(control, { + name: args.user, create: args.create, admin: !args.revoke, + }); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["set-whitelisted ", "Add or remove user from the whitelist", (yargs) => { + yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" }); + yargs.options({ + "remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await libLink.messages.setUserWhitelisted.send(control, { + name: args.user, create: args.create, whitelisted: !args.remove, + }); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["set-banned ", "Ban or pardon user from banlist", (yargs) => { + yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" }); + yargs.options({ + "pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false }, + "reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" }, + "create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + await libLink.messages.setUserBanned.send(control, { + name: args.user, create: args.create, banned: !args.pardon, reason: args.reason, + }); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["set-roles [roles...]", "Replace user roles", (yargs) => { + yargs.positional("user", { describe: "Name of user to change roles for", type: "string" }); + yargs.positional("roles", { describe: "roles to assign", type: "string" }); + }], + handler: async function(args, control) { + let response = await libLink.messages.listRoles.send(control); + + let resolvedRoles = []; + for (let roleName of args.roles) { + if (/^-?\d+$/.test(roleName)) { + let roleId = parseInt(roleName, 10); + resolvedRoles.push(roleId); + + } else { + let found = false; + for (let role of response.list) { + if (role.name === roleName) { + resolvedRoles.push(role.id); + found = true; + break; + } + } + + if (!found) { + throw new libErrors.CommandError(`No role named ${roleName}`); + } + } + } + + await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles }); + }, +})); + +userCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete user", (yargs) => { + yargs.positional("user", { describe: "Name of user to delete", type: "string" }); + }], + handler: async function(args, control) { + await libLink.messages.deleteUser.send(control, { name: args.user }); + }, +})); + +const logCommands = new libCommand.CommandTree({ name: "log", description: "Log inspection" }); +logCommands.add(new libCommand.Command({ + definition: ["follow", "follow cluster log", (yargs) => { + yargs.options({ + "all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false }, + "master": { describe: "Follow log of the master server", nargs: 0, type: "boolean", default: false }, + "slave": { describe: "Follow log of given slave", nargs: 1, type: "string", default: null }, + "instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null }, + }); + }], + handler: async function(args, control) { + if (!args.all && !args.master && !args.slave && !args.instance) { + logger.error("At least one of --all, --master, --slave and --instance must be passed"); + process.exitCode = 1; + return; + } + let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : []; + await control.setLogSubscriptions({ all: args.all, master: args.master, slave_ids, instance_ids }); + control.keepOpen = true; + }, +})); + +logCommands.add(new libCommand.Command({ + definition: ["query", "Query cluster log", (yargs) => { + yargs.options({ + "all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false }, + "master": { describe: "Query log of the master server", nargs: 0, type: "boolean", default: false }, + "slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null }, + "instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null }, + "max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null }, + "limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 }, + "start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + if (!args.all && !args.master && !args.slave && !args.instance) { + logger.error("At least one of --all, --master, --slave and --instance must be passed"); + process.exitCode = 1; + return; + } + let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : []; + let result = await libLink.messages.queryLog.send(control, { + all: args.all, + master: args.master, + slave_ids, + instance_ids, + max_level: args.maxLevel, + limit: args.limit, + order: args.start ? "asc" : "desc", + }); + + if (!args.start) { + result.log.reverse(); + } + + let stdoutLogger = winston.createLogger({ + level: "verbose", + levels, + format: new libLoggingUtils.TerminalFormat({ showTimestamp: true }), + transports: [ + new ConsoleTransport({ errorLevels: [], warnLevels: [] }), + ], + }); + for (let info of result.log) { + stdoutLogger.log(info); + } + }, +})); + +const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" }); +debugCommands.add(new libCommand.Command({ + definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }], + handler: async function(args, control) { + await libLink.messages.debugDumpWs.send(control); + control.keepOpen = true; + }, +})); + +async function registerCommands(controlPlugins, yargs) { + const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); + rootCommands.add(masterCommands); + rootCommands.add(slaveCommands); + rootCommands.add(instanceCommands); + rootCommands.add(permissionCommands); + rootCommands.add(roleCommands); + rootCommands.add(userCommands); + rootCommands.add(logCommands); + rootCommands.add(debugCommands); + + for (let controlPlugin of controlPlugins.values()) { + await controlPlugin.addCommands(rootCommands); + } + + for (let [name, command] of rootCommands.subCommands) { + if (name === command.name) { + command.register(yargs); + } + } + + return rootCommands; +} + +module.exports = { + registerCommands, +};