diff --git a/packages/ctl/src/command_ops.ts b/packages/ctl/src/command_ops.ts new file mode 100644 index 00000000..24d13c69 --- /dev/null +++ b/packages/ctl/src/command_ops.ts @@ -0,0 +1,4 @@ +export function print(...content: any) { + // eslint-disable-next-line no-console + console.log(...content); +} diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 142e26d0..ffb46193 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1,2266 +1,17 @@ -import fs from "fs-extra"; -import asTableModule from "as-table"; -import events from "events"; -import path from "path"; -import winston from "winston"; -import phin from "phin"; -import os from "os"; -import child_process from "child_process"; -import stream from "stream"; -import util from "util"; import type { Argv } from "yargs"; import * as lib from "@clusterio/lib"; -import { ConsoleTransport, levels, logger } from "@clusterio/lib"; -import type { Control } from "../ctl"; import type BaseCtlPlugin from "./BaseCtlPlugin"; - -const asTable = asTableModule.configure({ delimiter: " | " }); -const finished = util.promisify(stream.finished); - - -function print(...content: any) { - // eslint-disable-next-line no-console - console.log(...content); -} - -async function getEditor(argsEditor: string) { - // eslint-disable-next-line - return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined - // 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: string) { - let final: Record = {}; - 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) { - let split = 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 = split[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; -} - -function serializedConfigToString( - serializedConfig: lib.ConfigSchema, - configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig, - disallowedList: Record, -) { - let allConfigElements = ""; - for (let [name, value] of Object.entries(serializedConfig)) { - if (name in disallowedList) { - continue; - } - let desc = ""; - try { - desc += (configGroup.fieldDefinitions as any)[name]!.description; - } catch (err) { - desc += "No description found"; - } - // split onto two lines for readability and es-lint - if (String(value) === "null") { - value = ""; - } - allConfigElements += `${name} = ${value}\n\n`; - } - return allConfigElements; -} - -const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" }); -controllerCommands.add(new lib.Command({ - definition: ["stop", "Stop controller"], - handler: async function(_args: object, control: Control) { - await control.send(new lib.ControllerStopRequest()); - }, -})); - -controllerCommands.add(new lib.Command({ - definition: ["restart", "Restart controller"], - handler: async function(_args: object, control: Control) { - await control.send(new lib.ControllerRestartRequest()); - }, -})); -controllerCommands.add(new lib.Command({ - definition: ["update", "Update the controller", (yargs) => { - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { restart: boolean }, control: Control) { - await control.send(new lib.ControllerUpdateRequest()); - if (args.restart) { - await control.send(new lib.ControllerRestartRequest()); - } else { - print("Controller updated; a restart is required to apply the changes."); - } - }, -})); - -const controllerConfigCommands = new lib.CommandTree({ - name: "config", alias: ["c"], description: "controller config management", -}); - -controllerConfigCommands.add(new lib.Command({ - definition: ["list", "List controller configuration"], - handler: async function(args: object, control: Control) { - let config = await control.send(new lib.ControllerConfigGetRequest()); - - for (let [name, value] of Object.entries(config)) { - print(`${name} ${JSON.stringify(value)}`); - } - }, -})); - -controllerConfigCommands.add(new lib.Command({ - definition: ["set [value]", "Set field in controller 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: { field: string, value?: string, stdin?: boolean }, control: Control) { - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } else if (args.value === undefined) { - args.value = ""; - } - await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value)); - }, -})); - -controllerConfigCommands.add(new lib.Command({ - definition: ["set-prop [value]", "Set property of field in controller 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: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) { - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } - let value; - try { - if (args.value !== undefined) { - value = JSON.parse(args.value); - } - } catch (err: any) { - // See note for the instance version of set-prop - if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) { - throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`); - } - value = args.value; - } - await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value)); - }, -})); -controllerConfigCommands.add(new lib.Command({ - definition: ["edit [editor]", "Edit controller configuration", (yargs) => { - yargs.positional("editor", { - describe: "Editor to use", - type: "string", - default: "", - }); - }], - handler: async function(args: { editor: string }, control: Control) { - let config = await control.send(new lib.ControllerConfigGetRequest()); - let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); - let editor = await getEditor(args.editor); - if (editor === undefined) { - throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars - Try "ctl controller config edit "`); - } - let allConfigElements = serializedConfigToString( - config, lib.ControllerConfig, {} - ); - await fs.writeFile(tmpFile, allConfigElements); - let editorSpawn = child_process.spawn(editor, [tmpFile], { - stdio: "inherit", - detached: false, - }); - editorSpawn.on("data", (data) => { - process.stdout.pipe(data); - }); - await events.once(editorSpawn, "exit"); - const data = await fs.readFile(tmpFile, "utf8"); - const final = await configToKeyVal(data); - for (let index in final) { - if (index in final) { - try { - await control.send(new lib.ControllerConfigSetFieldRequest( - index, - 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 - } - } - } - try { - await fs.unlink(tmpFile); - } catch (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."); - } - }, -})); - -controllerCommands.add(controllerConfigCommands); - - -const controllerPluginCommands = new lib.CommandTree({ - name: "plugin", alias: ["p"], description: "controller plugin inspection", -}); -controllerPluginCommands.add(new lib.Command({ - definition: ["list", "List plugins on controller"], - handler: async function(args: object, control: Control) { - let url = new URL(control.config.get("control.controller_url")!); - url.pathname += "api/plugins"; - let response = await phin<[]>({ - url, - parse: "json", - core: { ca: control.tlsCa } as object, - }); - print(asTable(response.body)); - }, -})); -controllerPluginCommands.add(new lib.Command({ - definition: ["update ", "Update a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { plugin: string, restart: boolean }, control: Control) { - await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin)); - if (args.restart) { - await control.send(new lib.ControllerRestartRequest()); - } else { - print("Plugin updated; a restart is required to apply the changes."); - } - }, -})); -controllerPluginCommands.add(new lib.Command({ - definition: ["install ", "Install a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { plugin: string, restart: boolean }, control: Control) { - await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin)); - if (args.restart) { - await control.send(new lib.ControllerRestartRequest()); - } else { - print("Plugin installed; a restart is required to apply the changes."); - } - }, -})); - -controllerCommands.add(controllerPluginCommands); - - -const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" }); -hostCommands.add(new lib.Command({ - definition: ["stop ", "Stop the given host", (yargs) => { - yargs.positional("host", { describe: "Host to stop", type: "string" }); - }], - handler: async function(args: { host: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.HostStopRequest()); - }, -})); - -hostCommands.add(new lib.Command({ - definition: ["restart ", "Restart the given host", (yargs) => { - yargs.positional("host", { describe: "Host to restart", type: "string" }); - }], - handler: async function(args: { host: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - }, -})); - -hostCommands.add(new lib.Command({ - definition: [["list", "l"], "List hosts connected to the controller"], - handler: async function(args: object, control: Control) { - let hosts = await control.send(new lib.HostListRequest()); - print(asTable(hosts)); - }, -})); - -hostCommands.add(new lib.Command({ - definition: ["generate-token", "Generate token for a host", (yargs) => { - yargs.option("id", { type: "number", nargs: 1, describe: "Host id" }); - }], - handler: async function(args: { id?: number }, control: Control) { - let hostId = typeof args.id === "number" ? args.id : undefined; - let response = await control.send(new lib.HostGenerateTokenRequest(hostId)); - print(response); - }, -})); - -hostCommands.add(new lib.Command({ - definition: ["revoke-token ", "Revoke all tokens for a host", (yargs) => { - yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" }); - }], - handler: async function(args: { host: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.send(new lib.HostRevokeTokensRequest(hostId)); - }, -})); - -hostCommands.add(new lib.Command({ - definition: ["create-config", "Create host config", (yargs) => { - yargs.option("id", { type: "number", nargs: 1, describe: "Host id" }); - yargs.option("name", { type: "string", nargs: 1, describe: "Host name" }); - 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-host.json", - }); - }], - handler: async function( - args: { id?: number, name?: string, generateToken: boolean, output: string }, - control: Control - ) { - let config = await control.send( - new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken) - ); - - let content = JSON.stringify(config, null, "\t"); - if (args.output === "-") { - print(content); - } else { - logger.info(`Writing ${args.output}`); - try { - await fs.outputFile(args.output, content, { flag: "wx" }); - } catch (err: any) { - if (err.code === "EEXIST") { - throw new lib.CommandError(`File ${args.output} already exists`); - } - throw err; - } - } - }, -})); -hostCommands.add(new lib.Command({ - definition: ["update ", "Update the host", (yargs) => { - yargs.positional("host", { describe: "Host to update", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { host: string, restart: boolean }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.HostUpdateRequest()); - if (args.restart) { - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - } else { - print("Host updated; a restart is required to apply the changes."); - } - }, -})); - - -const hostConfigCommands = new lib.CommandTree({ - name: "config", alias: ["c"], description: "Host config management", -}); -hostConfigCommands.add(new lib.Command({ - definition: ["list ", "List configuration for a host", (yargs) => { - yargs.positional("host", { describe: "Host to list config for", type: "string" }); - }], - handler: async function(args: { host: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); - - for (let [name, value] of Object.entries(config)) { - print(`${name} ${JSON.stringify(value)}`); - } - }, -})); - -hostConfigCommands.add(new lib.Command({ - definition: ["set [value]", "Set field in host config", (yargs) => { - yargs.positional("host", { describe: "Host 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: { host: string, field: string, value?: string, stdin?: boolean }, - control: Control, - ) { - let hostId = await lib.resolveHost(control, args.host); - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } else if (args.value === undefined) { - args.value = ""; - } - await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value)); - }, -})); - -hostConfigCommands.add(new lib.Command({ - definition: ["set-prop [value]", "Set property of field in host config", (yargs) => { - yargs.positional("host", { describe: "Host 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: { host: string, field: string, prop: string, value?: string, stdin?: boolean }, - control: Control - ) { - let hostId = await lib.resolveHost(control, args.host); - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } - let value; - try { - if (args.value !== undefined) { - value = JSON.parse(args.value); - } - } catch (err: any) { - // See note for the instance version of set-prop - if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) { - throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`); - } - value = args.value; - } - await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value)); - }, -})); - -hostConfigCommands.add(new lib.Command({ - definition: ["edit [editor]", "Edit host configuration", (yargs) => { - yargs.positional("host", { describe: "Host to set config on", type: "string" }); - yargs.positional("editor", { - describe: "Editor to use", - type: "string", - default: "", - }); - }], - handler: async function(args: { host: string, editor: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); - let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); - let editor = await getEditor(args.editor); - if (editor === undefined) { - throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars - Try "ctl controller config edit "`); - } - let disallowedList = {"host.id": 0}; - let allConfigElements = serializedConfigToString( - config, - lib.HostConfig, - disallowedList - ); - await fs.writeFile(tmpFile, allConfigElements); - let editorSpawn = child_process.spawn(editor, [tmpFile], { - stdio: "inherit", - detached: false, - }); - editorSpawn.on("data", (data) => { - process.stdout.pipe(data); - }); - await events.once(editorSpawn, "exit"); - const data = await fs.readFile(tmpFile, "utf8"); - const final = await configToKeyVal(data); - for (let index in final) { - if (index in final) { - try { - await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest( - index, - 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); - } - } - } - try { - await fs.unlink(tmpFile); - } catch (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."); - } - }, -})); - -hostCommands.add(hostConfigCommands); - - -const hostPluginCommands = new lib.CommandTree({ - name: "plugin", alias: ["p"], description: "host plugin inspection", -}); -hostPluginCommands.add(new lib.Command({ - definition: ["list ", "List plugins on a host", (yargs) => { - yargs.positional("host", { describe: "Host to list for", type: "string" }); - }], - handler: async function(args: { host: string }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest()); - print(asTable(plugins.map(p => ({ - title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage, - })))); - }, -})); -hostPluginCommands.add(new lib.Command({ - definition: ["update ", "Update a plugin on a host", (yargs) => { - yargs.positional("host", { describe: "Host to update on", type: "string" }); - yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin)); - if (args.restart) { - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - } else { - print("Plugin updated; a restart is required to apply the changes."); - } - }, -})); -hostPluginCommands.add(new lib.Command({ - definition: ["install ", "Install a plugin on a host", (yargs) => { - yargs.positional("host", { describe: "Host to install on", type: "string" }); - yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); - yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); - }], - handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { - let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin)); - if (args.restart) { - await control.sendTo({ hostId }, new lib.HostRestartRequest()); - } else { - print("Plugin installed; a restart is required to apply the changes."); - } - }, -})); -hostCommands.add(hostPluginCommands); - - -const instanceCommands = new lib.CommandTree({ - name: "instance", alias: ["i"], description: "Instance management", -}); -instanceCommands.add(new lib.Command({ - definition: [["list", "l"], "List instances known to the controller"], - handler: async function(args: object, control: Control) { - let list = await control.send(new lib.InstanceDetailsListRequest()); - print(asTable(list)); - }, -})); - -instanceCommands.add(new lib.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" }, - "from": { type: "number", nargs: 1, describe: "Clone config from instance id" }, - }); - }], - handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) { - let instanceConfig = new lib.InstanceConfig("control"); - if (args.id !== undefined) { - instanceConfig.set("instance.id", args.id); - } - instanceConfig.set("instance.name", args.name); - await control.send(new lib.InstanceCreateRequest( - instanceConfig.toRemote("controller", [ - "instance.id", "instance.name", - ]), - args.from - )); - }, -})); - -const instanceConfigCommands = new lib.CommandTree({ - name: "config", alias: ["c"], description: "Instance config management", -}); -instanceConfigCommands.add(new lib.Command({ - definition: ["list ", "List configuration for an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to list config for", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - - for (let [name, value] of Object.entries(config)) { - print(`${name} ${JSON.stringify(value)}`); - } - }, -})); - -instanceConfigCommands.add(new lib.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: { instance: string, field: string, value?: string, stdin?: boolean }, - control: Control, - ) { - let instanceId = await lib.resolveInstance(control, args.instance); - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } else if (args.value === undefined) { - args.value = ""; - } - await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value)); - }, -})); - -instanceConfigCommands.add(new lib.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: { instance: string, field: string, prop: string, value?: string, stdin?: boolean }, - control: Control - ) { - let instanceId = await lib.resolveInstance(control, args.instance); - if (args.stdin) { - args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); - } - let value; - try { - if (args.value !== undefined) { - value = JSON.parse(args.value); - } - } catch (err: any) { - // 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: - // ----------------------- WARNING ------------------------------- - // This table is most likely invalid due a change in yargs parsing - // --------------------------------------------------------------- - // 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 lib.CommandError(`In parsing value '${args.value}': ${err.message}`); - } - value = args.value; - } - await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value)); - }, -})); - -instanceConfigCommands.add(new lib.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: { instance: string, editor: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); - let editor = await getEditor(args.editor); - if (editor === undefined) { - throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars - Try "ctl controller config edit "`); - } - let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; - let allConfigElements = serializedConfigToString( - config, - lib.InstanceConfig, - disallowedList - ); - await fs.writeFile(tmpFile, allConfigElements); - let editorSpawn = child_process.spawn(editor, [tmpFile], { - stdio: "inherit", - detached: false, - }); - editorSpawn.on("data", (data) => { - process.stdout.pipe(data); - }); - await events.once(editorSpawn, "exit"); - const data = await fs.readFile(tmpFile, "utf8"); - const final = await configToKeyVal(data); - for (let index in final) { - if (index in final) { - try { - await control.send(new lib.InstanceConfigSetFieldRequest( - instanceId, - index, - 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); - } - } - } - try { - await fs.unlink(tmpFile); - } catch (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 lib.Command({ - definition: ["assign [host]", "Assign instance to a host", (yargs) => { - yargs.positional("instance", { describe: "Instance to assign", type: "string" }); - yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" }); - }], - handler: async function(args: { instance: string, host?: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined; - await control.send(new lib.InstanceAssignRequest(instanceId, hostId)); - }, -})); - -async function loadMapSettings( - args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string}, -) { - let seed = args.seed; - let mapGenSettings; - let mapSettings; - if (args.mapExchangeString) { - let parsed = lib.readMapExchangeString(args.mapExchangeString); - mapGenSettings = parsed.map_gen_settings; - mapSettings = parsed.map_settings; - } - if (args.mapGenSettings) { - mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString()); - } - if (args.mapSettings) { - mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString()); - } - - return { - seed, - mapGenSettings, - mapSettings, - }; -} - -const instanceSaveCommands = new lib.CommandTree({ - name: "save", alias: ["s"], description: "Instance save management", -}); -instanceSaveCommands.add(new lib.Command({ - definition: ["list ", "list saves on an instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest()); - saves = saves.filter(save => save.instanceId === instanceId); - print(asTable(saves.map( - ({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() }) - ))); - }, -})); - -instanceSaveCommands.add(new lib.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: { - instance: string, - name: string, - seed?: number, - mapExchangeString?: string, - mapGenSettings?: string, - mapSettings?: string, - }, - control: Control - ) { - let instanceId = await lib.resolveInstance(control, args.instance); - let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo( - { instanceId }, - new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings), - ); - }, -})); - -instanceSaveCommands.add(new lib.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: { instance: string, oldName: string, newName: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName)); - }, -})); - -instanceSaveCommands.add(new lib.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: { instance: string, source: string, destination: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination)); - }, -})); - -instanceSaveCommands.add(new lib.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: { instance: string, filepath: string, name?: string }, control: Control) { - let filename = args.name || path.basename(args.filepath); - if (!filename.endsWith(".zip")) { - throw new lib.CommandError("Save name must end with .zip"); - } - // phin doesn't support streaming requests :( - let content = await fs.readFile(args.filepath); - - let instanceId = await lib.resolveInstance(control, args.instance); - let url = new URL(control.config.get("control.controller_url")!); - url.pathname += "api/upload-save"; - url.searchParams.append("instance_id", String(instanceId)); - url.searchParams.append("filename", filename); - - let result = await phin<{ errors?: string[], request_errors?: string[], saves?: string[] }>({ - url, method: "POST", - headers: { - "X-Access-Token": control.config.get("control.controller_token"), - "Content-Type": "application/zip", - }, - core: { ca: control.tlsCa } as object, - 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 lib.CommandError("Uploading save failed"); - } - }, -})); - -instanceSaveCommands.add(new lib.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: { - sourceInstance: string, - sourceSave: string, - targetInstance: string, - targetSave: string, - copy: boolean, - }, - control: Control - ) { - let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance); - let targetInstanceId = await lib.resolveInstance(control, args.targetInstance); - let storedName = await control.send( - new lib.InstanceTransferSaveRequest( - sourceInstanceId, - args.sourceSave, - targetInstanceId, - args.targetSave || args.sourceSave, - args.copy - ) - ); - print(`Transferred as ${storedName} to ${args.targetInstance}.`); - }, -})); - -instanceSaveCommands.add(new lib.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: { instance: string, save: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save)); - - let url = new URL(control.config.get("control.controller_url")!); - url.pathname += `api/stream/${streamId}`; - let response = await phin({ - url, method: "GET", - core: { ca: control.tlsCa } as object, - 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: any) { - if (err.code === "EEXIST") { - tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip"); - } else { - throw err; - } - } - } - response.pipe(writeStream); - await finished(writeStream); - - let filename = await lib.findUnusedName(".", args.save, ".zip"); - await fs.rename(tempFilename, filename); - - logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`); - }, -})); - -instanceSaveCommands.add(new lib.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: { instance: string, save: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save)); - }, -})); -instanceCommands.add(instanceSaveCommands); - -instanceCommands.add(new lib.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: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest()); - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["extract-players ", "Extract players from running save into the cluster.", (yargs) => { - yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest()); - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["start ", "Start instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to start", type: "string" }); - yargs.options({ - "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, - "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { - const instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save)); - control.keepOpen = args.keepOpen; - }, -})); -instanceCommands.add(new lib.Command({ - definition: ["restart ", "Restart instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to restart", type: "string" }); - yargs.options({ - "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, - "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { - const instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save)); - control.keepOpen = args.keepOpen; - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => { - yargs.options({ - "force": { - describe: "Start instances even if they have exclude_from_start_all set", - nargs: 0, - type: "boolean", - default: false, - }, - }); - }], - handler: async function(args: { force: boolean }, control: Control) { - // Get all instances - const instances = await control.send(new lib.InstanceDetailsListRequest()); - - // Filter instances that should be started - const instancesToStart = instances.filter((instance: lib.InstanceDetails) => { - // Only start instances that are currently stopped - if (instance.status !== "stopped") { - return false; - } - - // Respect exclude_from_start_all setting unless --force is used - if (!args.force && instance.excludeFromStartAll) { - return false; - } - - return true; - }); - - if (instancesToStart.length === 0) { - print("No instances to start."); - return; - } - - print(`Starting ${instancesToStart.length} instance(s):`); - for (const instance of instancesToStart) { - print(` - ${instance.name} (ID: ${instance.id})`); - } - - // Start all filtered instances - const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => { - try { - await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest()); - print(`✓ Started ${instance.name}`); - } catch (error) { - print(`✗ Failed to start ${instance.name}: ${error}`); - } - }); - - await Promise.all(startPromises); - print("Start all operation completed."); - }, -})); - -instanceCommands.add(new lib.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: { - instance: string, - scenario: string, - seed?: number, - mapExchangeString?: string, - mapGenSettings?: string, - mapSettings?: string, - keepOpen: boolean, - }, - control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await control.sendTo( - { instanceId }, - new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings), - ); - control.keepOpen = args.keepOpen; - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["stop ", "Stop instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to stop", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceStopRequest()); - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["stop-all", "Stop all running instances", () => {}], - handler: async function(_args, control: Control) { - // Get all instances - const instances = await control.send(new lib.InstanceDetailsListRequest()); - - // Filter instances that should be stopped - const instancesToStop = instances - .filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status)); - - if (instancesToStop.length === 0) { - print("No instances to stop."); - return; - } - - print(`Stopping ${instancesToStop.length} instance(s):`); - for (const instance of instancesToStop) { - print(` - ${instance.name} (ID: ${instance.id})`); - } - - // Stop all filtered instances - const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => { - try { - await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest()); - print(`✓ Stopped ${instance.name}`); - } catch (error) { - print(`✗ Failed to stop ${instance.name}: ${error}`); - } - }); - - await Promise.all(stopPromises); - print("Stop all operation completed."); - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["kill ", "Kill instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to kill", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instanceIds: [instanceId] }); - await control.sendTo({ instanceId }, new lib.InstanceKillRequest()); - }, -})); - -instanceCommands.add(new lib.Command({ - definition: ["delete ", "Delete instance", (yargs) => { - yargs.positional("instance", { describe: "Instance to delete", type: "string" }); - }], - handler: async function(args: { instance: string }, control: Control) { - let instanceId = await lib.resolveInstance(control, args.instance); - await control.send(new lib.InstanceDeleteRequest(instanceId)); - }, -})); - -instanceCommands.add(new lib.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: { instance: string, command: string }, control: Control) { - let result = await control.sendTo( - { instanceId: await lib.resolveInstance(control, args.instance) }, - new lib.InstanceSendRconRequest(args.command), - ); - - // Factorio includes a newline in its response output. - process.stdout.write(result); - }, -})); - -const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" }); -modPackCommands.add(new lib.Command({ - definition: ["show ", "Show details of mod pack", (yargs) => { - yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" }); - }], - handler: async function(args: { modPack: string }, control: Control) { - let mods = new Map( - (await control.send(new lib.ModListRequest())).map(m => [m.id, m]) - ); - let modPack = await control.send( - new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) - ); - - for (let [field, value] of Object.entries(modPack)) { - if (field === "mods") { - print(`${field}:`); - for (let [name, entry] of value) { - const mod = mods.get(`${name}_${entry.version}`); - const missing = !mod && "? "; - const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! "; - const warn = missing || badChecksum || ""; - const enabled = entry.enabled ? "" : "(disabled) "; - print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); - } - } else if (field === "settings") { - print(`${field}:`); - for (let [scope, settings] of Object.entries(value)) { - print(` ${scope}:`); - for (let [setting, settingValue] of settings as any) { - print(` ${setting}: ${JSON.stringify(settingValue.value)}`); - } - } - } else if (field === "exportManifest") { - print(`${field}:`); - if (value && value.assets) { - print(" assets:"); - for (let [name, fileName] of Object.entries(value.assets)) { - print(` ${name}: ${fileName}`); - } - } - - } else { - print(`${field}: ${value}`); - } - } - }, -})); - -modPackCommands.add(new lib.Command({ - definition: [["list", "l"], "List mod packs in the cluster"], - handler: async function(args: object, control: Control) { - let modPacks = await control.send(new lib.ModPackListRequest()); - let fields = ["id", "name", "factorioVersion"]; - for (let entry of modPacks) { - for (let field of Object.keys(entry)) { - if (!fields.includes(field)) { - // @ts-expect-error terrible hack - delete entry[field]; - } - } - } - print(asTable(modPacks)); - }, -})); - -function setModPackSettings( - modPack: lib.ModPack, - args: { - boolSetting?: string[], - intSetting?: string[], - doubleSetting?: string[], - stringSetting?: string[], - colorSetting?: string[], - }, -) { - function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) { - for (let i = 0; i + 2 < settings.length; i += 3) { - const [scope, name, value] = settings.slice(i, i + 3); - if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { - throw new lib.CommandError( - `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` - ); - } - const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user"; - modPack.settings[checkedScope].set(name, { value: cast(value) }); - } - } - - doSettings(args.boolSetting || [], value => { - if (!["true", "false"].includes(value)) { - throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`); - } - return value === "true"; - }); - doSettings(args.intSetting || [], value => { - // eslint-disable-next-line radix - let number = Number.parseInt(value); - if (isNaN(number)) { - throw new lib.CommandError(`int value must be an integer, not ${value}`); - } - return number; - }); - doSettings(args.doubleSetting || [], value => { - let number = Number.parseFloat(value); - if (isNaN(number)) { - throw new lib.CommandError(`double value must be a number, not ${value}`); - } - return number; - }); - doSettings(args.stringSetting || [], value => value); - doSettings(args.colorSetting || [], value => { - let color = JSON.parse(value); - if (typeof color !== "object" || color === null) { - throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties"); - } - return color; - }); -} - -function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { - for (let mod of mods || []) { - const [name, version, sha1] = mod.split(":"); - if (!version) { - throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); - } - if (!lib.isFullVersion(version)) { - throw new lib.CommandError("version must match the format digit.digit.digit"); - } - if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { - throw new lib.CommandError("sha1 must be a 40 digit lower case hex string"); - } - modPack.mods.set(name, { name, enabled: true, version, sha1 }); - } -} - -function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) { - for (let mod of mods || []) { - if (!modPack.mods.has(mod)) { - throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`); - } - modPack.mods.get(mod)!.enabled = enabled; - } -} - -modPackCommands.add(new lib.Command({ - definition: ["create ", "Create mod pack", (yargs) => { - yargs.positional("name", { describe: "Name of mod pack to create", type: "string" }); - yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" }); - yargs.options({ - "description": { describe: "Description for mod pack", type: "string" }, - "mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" }, - "disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" }, - "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, - "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, - "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, - "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, - "color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" }, - }); - }], - handler: async function( - args: { - name: string, - factorioVersion: string, - description?: string, - mods?: string[], - disabledMods?: string[], - boolSetting?: string[], - intSetting?: string[], - doubleSetting?: string[], - stringSetting?: string[], - colorSetting?: string[], - }, - control: Control, - ) { - const modPack = new lib.ModPack(); - modPack.name = args.name; - if (args.description) { modPack.description = args.description; } - if (args.factorioVersion) { - if (!lib.isPartialVersion(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); - } - modPack.factorioVersion = args.factorioVersion; - } - setModPackMods(modPack, args.mods); - setModPackModsEnabled(modPack, args.disabledMods, false); - setModPackSettings(modPack, args); - await control.send(new lib.ModPackCreateRequest(modPack)); - print(`Created mod pack ${modPack.name} (${modPack.id})`); - }, -})); - -modPackCommands.add(new lib.Command({ - definition: ["import ", "Import mod pack string", (yargs) => { - yargs.positional("string", { describe: "Mod pack string to import", type: "string" }); - }], - handler: async function(args: { string: string }, control: Control) { - const modPack = lib.ModPack.fromModPackString(args.string); - await control.send(new lib.ModPackCreateRequest(modPack)); - print(`Created mod pack ${modPack.name} (${modPack.id})`); - }, -})); - -modPackCommands.add(new lib.Command({ - definition: ["export ", "Export mod pack string", (yargs) => { - yargs.positional("string", { describe: "Mod pack to export", type: "string" }); - }], - handler: async function(args: { modPack: string }, control: Control) { - const modPack = await control.send( - new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) - ); - print(modPack.toModPackString()); - }, -})); - -modPackCommands.add(new lib.Command({ - definition: ["edit ", "Edit mod pack", (yargs) => { - yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" }); - yargs.options({ - "name": { describe: "New name for mod pack", type: "string" }, - "description": { describe: "New description for mod pack", type: "string" }, - "factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" }, - "add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" }, - "enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" }, - "disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" }, - "remove-mods": { describe: "Name of mods to remove", array: true, type: "string" }, - "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, - "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, - "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, - "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, - "color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" }, - "remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" }, - }); - }], - handler: async function( - args: { - modPack: string, - name?: string, - description?: string, - factorioVersion?: string, - addMods?: string[], - enableMods?: string[], - disableMods?: string[], - removeMods?: string[], - boolSetting?: string[], - intSetting?: string[], - doubleSetting?: string[], - stringSetting?: string[], - colorSetting?: string[], - removeSetting?: string[], - }, - control: Control, - ) { - const modPack = await control.send( - new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) - ); - - if (args.name) { modPack.name = args.name; } - if (args.description) { modPack.description = args.description; } - if (args.factorioVersion) { - if (!lib.isPartialVersion(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); - } - modPack.factorioVersion = args.factorioVersion; - } - - setModPackMods(modPack, args.addMods); - for (let name of args.removeMods || []) { - if (modPack.mods.has(name)) { - modPack.mods.delete(name); - } else { - logger.warn(`Mod ${name} did not exist on ${modPack.name}`); - } - } - setModPackModsEnabled(modPack, args.disableMods, false); - setModPackModsEnabled(modPack, args.enableMods, true); - - setModPackSettings(modPack, args); - if (args.removeSetting) { - for (let i = 0; i + 1 < args.removeSetting.length; i += 2) { - const [scope, name] = args.removeSetting.slice(i, i + 2); - if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { - throw new lib.CommandError( - `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` - ); - } - const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user"; - if (modPack.settings[checkedScope].has(name)) { - modPack.settings[checkedScope].delete(name); - } else { - logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`); - } - } - } - await control.send(new lib.ModPackUpdateRequest(modPack)); - }, -})); - -modPackCommands.add(new lib.Command({ - definition: ["delete ", "Delete mod pack", (yargs) => { - yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" }); - }], - handler: async function(args: { modPack: string }, control: Control) { - const id = await lib.resolveModPack(control, args.modPack); - await control.send(new lib.ModPackDeleteRequest(id)); - }, -})); - -const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" }); -modCommands.add(new lib.Command({ - definition: ["show ", "Show details for a mod stored in the cluster", (yargs) => { - yargs.positional("name", { describe: "Mod name to show details for", type: "string" }); - yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); - }], - handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { - throw new lib.CommandError("mod-version must match format digit.digit.digit"); - } - let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); - for (let [field, value] of Object.entries(modInfo)) { - if (value instanceof Array) { - print(`${field}:`); - for (let entry of value) { - print(` ${entry}`); - } - } else { - print(`${field}: ${value}`); - } - } - }, -})); - -modCommands.add(new lib.Command({ - definition: [["list", "l"], "List mods stored in the cluster", (yargs) => { - yargs.options({ - "fields": { - describe: "Fields to show, supports 'all'.", - array: true, - type: "string", - default: ["name", "version", "title", "factorio_version"], - }, - }); - }], - handler: async function(args: { fields: string[] }, control: Control) { - let mods = await control.send(new lib.ModListRequest()); - if (!args.fields.includes("all")) { - for (let entry of mods) { - for (let field of Object.keys(entry)) { - if (!args.fields.includes(field)) { - // @ts-expect-error terrible hack - delete entry[field]; - } - } - } - } - print(asTable(mods)); - }, -})); - -modCommands.add(new lib.Command({ - definition: ["search [query]", "Search mods stored in the cluster", (yargs) => { - yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" }); - yargs.positional("query", { describe: "Search query", type: "string", default: "" }); - yargs.options({ - "page": { - describe: "Result page to show", - type: "number", - default: 1, - }, - "page-size": { - describe: "Results per page to show", - type: "number", - default: 10, - }, - "sort": { - describe: "sort results by given field", - type: "string", - }, - "sort-order": { - describe: "order to sort results in (asc/desc)", - type: "string", - default: "asc", - }, - "fields": { - describe: "Fields to show, supports 'all'.", - array: true, - type: "string", - default: ["name", "version", "title", "factorio_version"], - }, - }); - }], - handler: async function( - args: { - factorioVersion: string, - query: string, - page: number, - pageSize: number, - sort?: string, - sortOrder: "asc" | "desc", - fields: string[], - }, - control: Control - ) { - let response = await control.send(new lib.ModSearchRequest( - args.query, - lib.normaliseApiVersion(args.factorioVersion as any), - args.page, - args.pageSize, - args.sort, - args.sortOrder - )); - let results = response.results.flatMap(result => result.versions); - if (!args.fields.includes("all")) { - for (let entry of results) { - for (let field of Object.keys(entry)) { - if (!args.fields.includes(field)) { - // @ts-expect-error terrible hack - delete entry[field]; - } - } - } - } - for (let issue of response.queryIssues) { - print(issue); - } - print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`); - print(asTable(results)); - }, -})); - -modCommands.add(new lib.Command({ - definition: ["upload ", "Upload mod to the cluster", (yargs) => { - yargs.positional("file", { describe: "File to upload", type: "string" }); - }], - handler: async function(args: { file: string }, control: Control) { - let filename = path.basename(args.file); - if (!filename.endsWith(".zip")) { - throw new lib.CommandError("Mod filename must end with .zip"); - } - // phin doesn't support streaming requests :( - let content = await fs.readFile(args.file); - - let url = new URL(control.config.get("control.controller_url")!); - url.pathname += "api/upload-mod"; - url.searchParams.append("filename", filename); - - let result = await phin< - { errors?: [], request_errors?: [], mods: Parameters[0][]} - >({ - url, method: "POST", - headers: { - "X-Access-Token": control.config.get("control.controller_token"), - "Content-Type": "application/zip", - }, - core: { ca: control.tlsCa } as object, - 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.mods && result.body.mods.length) { - const mod = lib.ModInfo.fromJSON(result.body.mods[0]); - logger.info(`Successfully uploaded ${mod.filename}`); - } - - if ((result.body.errors || []).length || (result.body.request_errors || []).length) { - throw new lib.CommandError("Uploading mod failed"); - } - }, -})); - -modCommands.add(new lib.Command({ - definition: ["download ", "Download a mod from the cluster", (yargs) => { - yargs.positional("name", { describe: "Internal name of mod to download", type: "string" }); - yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); - }], - handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { - throw new lib.CommandError("mod-version must match format digit.digit.digit"); - } - let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); - - let url = new URL(control.config.get("control.controller_url")!); - url.pathname += `api/stream/${streamId}`; - let response = await phin({ - url, method: "GET", - core: { ca: control.tlsCa } as object, - stream: true, - }); - - let writeStream; - let filename = `${args.name}_${args.modVersion}.zip`; - let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); - while (true) { - try { - writeStream = fs.createWriteStream(tempFilename, { flags: "wx" }); - await events.once(writeStream, "open"); - break; - } catch (err: any) { - if (err.code === "EEXIST") { - tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip"); - } else { - throw err; - } - } - } - response.pipe(writeStream); - await finished(writeStream); - await fs.rename(tempFilename, filename); - - logger.info(`Downloaded ${filename}`); - }, -})); - -modCommands.add(new lib.Command({ - definition: ["delete ", "Delete a mod stored in the cluster", (yargs) => { - yargs.positional("name", { describe: "Name of mod to delete", type: "string" }); - yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); - }], - handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { - throw new lib.CommandError("mod-version must match format digit.digit.digit"); - } - await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); - }, -})); - -const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" }); -permissionCommands.add(new lib.Command({ - definition: [["list", "l"], "List permissions in the cluster"], - handler: async function(args: object, control: Control) { - let permissions = await control.send(new lib.PermissionListRequest()); - print(asTable(permissions)); - }, -})); - - -const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" }); -roleCommands.add(new lib.Command({ - definition: [["list", "l"], "List roles in the cluster"], - handler: async function(args: object, control: Control) { - let roles = await control.send(new lib.RoleListRequest()); - print(asTable(roles)); - }, -})); - -roleCommands.add(new lib.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: { name: string, description: string, permissions: string[] }, control: Control) { - let id = await control.send(new lib.RoleCreateRequest( - args.name, - args.description, - args.permissions, - )); - logger.info(`Created role ID ${id}`); - }, -})); - -roleCommands.add(new lib.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: { - role: string, - name?: string, - description?: string, - setPerms?: string[], - addPerms?: string[], - removePerms?: string[], - grantDefault?: boolean, - }, - control: Control - ) { - let role = await lib.retrieveRole(control, args.role); - - if (args.name !== undefined) { - role.name = args.name; - } - if (args.description !== undefined) { - role.description = args.description; - } - if (args.addPerms) { - for (const perm of args.addPerms) { - role.permissions.add(perm); - } - } - if (args.removePerms) { - for (let perm of args.removePerms) { - role.permissions.delete(perm); - } - } - if (args.setPerms !== undefined) { - role.permissions = new Set(args.setPerms); - } - await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions])); - - if (args.grantDefault) { - await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id)); - } - }, -})); - -roleCommands.add(new lib.Command({ - definition: ["delete ", "Delete role", (yargs) => { - yargs.positional("role", { describe: "Role to delete", type: "string" }); - }], - handler: async function(args: { role: string }, control: Control) { - let role = await lib.retrieveRole(control, args.role); - await control.send(new lib.RoleDeleteRequest(role.id)); - }, -})); - - -const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" }); -userCommands.add(new lib.Command({ - definition: ["show ", "Show details for one user", (yargs) => { - yargs.positional("name", { decribe: "Name of user to show", type: "string" }); - yargs.options({ - "instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: { name: string, instanceStats: boolean }, control: Control) { - let user = await control.send(new lib.UserGetRequest(args.name)); - Object.assign(user, user.playerStats); - // @ts-expect-error Terrible hack - delete user.playerStats; - let instanceStats = user.instanceStats; - // @ts-expect-error Terrible hack - delete user.instanceStats; - print(asTable(Object.entries(user).map(([property, value]) => ({ property, value })))); - - if (args.instanceStats) { - let instances = await control.send(new lib.InstanceDetailsListRequest()); - function instanceName(id: number) { - let instance = instances.find(i => i.id === id); - if (instance) { - return instance.name; - } - return ""; - } - for (let [id, playerInstanceStats] of instanceStats || []) { - print(); - print(`Instance ${instanceName(id)} (${id}):`); - print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value })))); - } - } - }, -})); - -userCommands.add(new lib.Command({ - definition: [["list", "l"], "List user in the cluster", (yargs) => { - yargs.options({ - "stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false }, - "attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) { - let users = await control.send(new lib.UserListRequest()); - for (let user of users) { - if (args.stats) { - Object.assign(user, user.playerStats); - } - // @ts-expect-error Terrible hack - delete user.playerStats; - // @ts-expect-error Terrible hack - delete user.isDeleted; - // @ts-expect-error Terrible hack - delete user.banReason; - // @ts-expect-error Terrible hack - delete user.instanceStats; - if (!args.attributes) { - // @ts-expect-error Terrible hack - delete user.isAdmin; - // @ts-expect-error Terrible hack - delete user.isWhitelisted; - // @ts-expect-error Terrible hack - delete user.isBanned; - } - } - print(asTable(users)); - }, -})); - -userCommands.add(new lib.Command({ - definition: ["create ", "Create a user", (yargs) => { - yargs.positional("name", { describe: "Name of user to create", type: "string" }); - }], - handler: async function(args: { name: string }, control: Control) { - await control.send(new lib.UserCreateRequest(args.name)); - }, -})); - -userCommands.add(new lib.Command({ - definition: ["revoke-token ", "Revoke token for user", (yargs) => { - yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" }); - }], - handler: async function(args: { name: string }, control: Control) { - await control.send(new lib.UserRevokeTokenRequest(args.name)); - }, -})); - -userCommands.add(new lib.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: { user: string, revoke: boolean, create: boolean }, control: Control) { - await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke)); - }, -})); - -userCommands.add(new lib.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: { user: string, remove: boolean, create: boolean }, control: Control) { - await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove)); - }, -})); - -userCommands.add(new lib.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: { - user: string, - pardon: boolean, - reason: string, - create: boolean - }, - control: Control - ) { - await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason)); - }, -})); - -userCommands.add(new lib.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: { user: string, roles: string[] }, control: Control) { - let roles = await control.send(new lib.RoleListRequest()); - - 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 roles) { - if (role.name === roleName) { - resolvedRoles.push(role.id); - found = true; - break; - } - } - - if (!found) { - throw new lib.CommandError(`No role named ${roleName}`); - } - } - } - - await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles)); - }, -})); - -userCommands.add(new lib.Command({ - definition: ["delete ", "Delete user", (yargs) => { - yargs.positional("user", { describe: "Name of user to delete", type: "string" }); - }], - handler: async function(args: { user: string }, control: Control) { - await control.send(new lib.UserDeleteRequest(args.user)); - }, -})); - -async function handleImportOrRestore(args: { - filepath: string, - users: boolean, - bans: boolean, - admins: boolean, - whitelist: boolean, - noBackup?: boolean, -}, control: Control, restore: boolean) { - let importType: ConstructorParameters[0]; - const data = JSON.parse((await fs.readFile(args.filepath)).toString()); - const optionCount = [args.users, args.bans, args.admins, args.whitelist] - .reduce((acc, bool) => (bool ? acc + 1 : acc), 0); - - if (optionCount > 1) { - throw new lib.CommandError("Can not specify multiple options"); - } - - // Assign based on options or attempt to guess based on filename - const filename = path.basename(args.filepath); - if (args.users || (optionCount === 0 && data.export_version)) { - importType = "users"; - } else if (args.bans || (optionCount === 0 && filename.includes("ban"))) { - importType = "bans"; - } else if (args.admins || (optionCount === 0 && filename.includes("admin"))) { - importType = "admins"; - } else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) { - importType = "whitelist"; - } else { - throw new lib.CommandError("Unknown json file, please specify an option"); - } - - const backup = await control.send( - new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore) - ); - - print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`); - if (restore && backup && !args.noBackup) { - await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 }); - print(`Wrote backup to ${importType}-backup.json`); - } -} - -userCommands.add(new lib.Command({ - definition: ["import ", "Import user data from a file", (yargs) => { - yargs.positional("filepath", { describe: "Path to the file to import", type: "string" }); - yargs.options({ - "users": { describe: "Import users json", nargs: 0, type: "boolean", default: false }, - "bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false }, - "admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false }, - "whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: any, control: any) { - await handleImportOrRestore(args, control, false); - }, -})); - -userCommands.add(new lib.Command({ - definition: ["restore ", "Restore user data from a file", (yargs) => { - yargs.positional("filepath", { describe: "Path to the file to import", type: "string" }); - yargs.options({ - "users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false }, - "bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false }, - "admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false }, - "whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false }, - "no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: any, control: any) { - await handleImportOrRestore(args, control, true); - }, -})); - -userCommands.add(new lib.Command({ - definition: ["export ", "Export user data to a file", (yargs) => { - yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" }); - yargs.options({ - "users": { describe: "Export users json", nargs: 0, type: "boolean", default: false }, - "bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false }, - "admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false }, - "whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false }, - }); - }], - handler: async function(args: { - filepath: string, - users: boolean, - bans: boolean, - admins: boolean, - whitelist: boolean, - }, control: Control) { - let exportType = "users" as ConstructorParameters[0]; - const optionCount = [args.bans, args.admins, args.whitelist] - .reduce((acc, bool) => (bool ? acc + 1 : acc), 0); - - if (optionCount > 1) { - throw new lib.CommandError("Can not specify multiple options"); - } - - // Assign based on options or attempt to guess based on filename - if (args.users) { - exportType = "users"; - } else if (args.bans) { - exportType = "bans"; - } else if (args.admins) { - exportType = "admins"; - } else if (args.whitelist) { - exportType = "whitelist"; - } - - await fs.writeJSON(args.filepath, await control.send( - new lib.UserBulkExportRequest(exportType) - ), { spaces: 2 }); - print(`Exported ${exportType} to ${args.filepath}`); - }, -})); - -const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" }); -logCommands.add(new lib.Command({ - definition: ["follow", "follow cluster log", (yargs) => { - yargs.options({ - "all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false }, - "controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false }, - "host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null }, - "instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null }, - }); - }], - handler: async function( - args: { all: boolean, controller: boolean, host: string | null, instance: string | null }, - control: Control - ) { - if (!args.all && !args.controller && !args.host && !args.instance) { - logger.error("At least one of --all, --controller, --host and --instance must be passed"); - process.exitCode = 1; - return; - } - let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : []; - let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : []; - await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds }); - control.keepOpen = true; - }, -})); - -logCommands.add(new lib.Command({ - definition: ["query", "Query cluster log", (yargs) => { - yargs.options({ - "all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false }, - "controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false }, - "host": { describe: "Query log of given host", 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: undefined }, - "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: { - all: boolean, - controller: boolean, - host: string | null, - instance: string | null, - maxLevel?: string, - limit: number, - start: boolean, - }, - control: Control - ) { - if (!args.all && !args.controller && !args.host && !args.instance) { - logger.error("At least one of --all, --controller, --host and --instance must be passed"); - process.exitCode = 1; - return; - } - let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : []; - let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : []; - let result = await control.send(new lib.LogQueryRequest( - args.all, - args.controller, - hostIds, - instanceIds, - args.maxLevel as keyof typeof levels, - args.limit, - args.start ? "asc" : "desc", - )); - - if (!args.start) { - result.log.reverse(); - } - - let stdoutLogger = winston.createLogger({ - level: "verbose", - levels, - format: new lib.TerminalFormat({ showTimestamp: true }), - transports: [ - new ConsoleTransport({ errorLevels: [], warnLevels: [] }), - ], - }); - for (let info of result.log) { - stdoutLogger.log(info as any); - } - }, -})); - -const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" }); -debugCommands.add(new lib.Command({ - definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"], - handler: async function(args: object, control: Control) { - await control.send(new lib.DebugDumpWsRequest()); - control.keepOpen = true; - }, -})); +import { controllerCommands } from "./commands_controller"; +import { hostCommands } from "./commands_host"; +import { instanceCommands } from "./commands_instance"; +import { modPackCommands } from "./commands_mod_pack"; +import { modCommands } from "./commands_mod"; +import { permissionCommands } from "./commands_permission"; +import { roleCommands } from "./commands_role"; +import { userCommands } from "./commands_user"; +import { logCommands } from "./commands_log"; +import { debugCommands } from "./commands_debug"; export async function registerCommands(ctlPlugins: Map, yargs: Argv) { const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts new file mode 100644 index 00000000..9b6481d5 --- /dev/null +++ b/packages/ctl/src/commands_controller.ts @@ -0,0 +1,206 @@ +import fs from "fs-extra"; +import asTableModule from "as-table"; +import events from "events"; +import phin from "phin"; +import os from "os"; +import child_process from "child_process"; + +import * as lib from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; +import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" }); +controllerCommands.add(new lib.Command({ + definition: ["stop", "Stop controller"], + handler: async function(_args: object, control: Control) { + await control.send(new lib.ControllerStopRequest()); + }, +})); + +controllerCommands.add(new lib.Command({ + definition: ["restart", "Restart controller"], + handler: async function(_args: object, control: Control) { + await control.send(new lib.ControllerRestartRequest()); + }, +})); +controllerCommands.add(new lib.Command({ + definition: ["update", "Update the controller", (yargs) => { + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { restart: boolean }, control: Control) { + await control.send(new lib.ControllerUpdateRequest()); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Controller updated; a restart is required to apply the changes."); + } + }, +})); + +const controllerConfigCommands = new lib.CommandTree({ + name: "config", alias: ["c"], description: "controller config management", +}); + +controllerConfigCommands.add(new lib.Command({ + definition: ["list", "List controller configuration"], + handler: async function(args: object, control: Control) { + let config = await control.send(new lib.ControllerConfigGetRequest()); + + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); + } + }, +})); + +controllerConfigCommands.add(new lib.Command({ + definition: ["set [value]", "Set field in controller 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: { field: string, value?: string, stdin?: boolean }, control: Control) { + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value)); + }, +})); + +controllerConfigCommands.add(new lib.Command({ + definition: ["set-prop [value]", "Set property of field in controller 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: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) { + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let value; + try { + if (args.value !== undefined) { + value = JSON.parse(args.value); + } + } catch (err: any) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) { + throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + value = args.value; + } + await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value)); + }, +})); +controllerConfigCommands.add(new lib.Command({ + definition: ["edit [editor]", "Edit controller configuration", (yargs) => { + yargs.positional("editor", { + describe: "Editor to use", + type: "string", + default: "", + }); + }], + handler: async function(args: { editor: string }, control: Control) { + let config = await control.send(new lib.ControllerConfigGetRequest()); + let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === undefined) { + throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl controller config edit "`); + } + let allConfigElements = serializedConfigToString( + config, lib.ControllerConfig, {} + ); + await fs.writeFile(tmpFile, allConfigElements); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + await events.once(editorSpawn, "exit"); + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await control.send(new lib.ControllerConfigSetFieldRequest( + index, + 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 + } + } + } + try { + await fs.unlink(tmpFile); + } catch (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."); + } + }, +})); + +controllerCommands.add(controllerConfigCommands); + + +const controllerPluginCommands = new lib.CommandTree({ + name: "plugin", alias: ["p"], description: "controller plugin inspection", +}); +controllerPluginCommands.add(new lib.Command({ + definition: ["list", "List plugins on controller"], + handler: async function(args: object, control: Control) { + let url = new URL(control.config.get("control.controller_url")!); + url.pathname += "api/plugins"; + let response = await phin<[]>({ + url, + parse: "json", + core: { ca: control.tlsCa } as object, + }); + print(asTable(response.body)); + }, +})); +controllerPluginCommands.add(new lib.Command({ + definition: ["update ", "Update a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +controllerPluginCommands.add(new lib.Command({ + definition: ["install ", "Install a plugin on the controller", (yargs) => { + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { plugin: string, restart: boolean }, control: Control) { + await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); + +controllerCommands.add(controllerPluginCommands); diff --git a/packages/ctl/src/commands_debug.ts b/packages/ctl/src/commands_debug.ts new file mode 100644 index 00000000..66559bd1 --- /dev/null +++ b/packages/ctl/src/commands_debug.ts @@ -0,0 +1,11 @@ +import * as lib from "@clusterio/lib"; +import type { Control } from "../ctl"; + +export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" }); +debugCommands.add(new lib.Command({ + definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"], + handler: async function(args: object, control: Control) { + await control.send(new lib.DebugDumpWsRequest()); + control.keepOpen = true; + }, +})); diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts new file mode 100644 index 00000000..16af4dfe --- /dev/null +++ b/packages/ctl/src/commands_host.ts @@ -0,0 +1,305 @@ +import fs from "fs-extra"; +import asTableModule from "as-table"; +import events from "events"; +import os from "os"; +import child_process from "child_process"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; +import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" }); +hostCommands.add(new lib.Command({ + definition: ["stop ", "Stop the given host", (yargs) => { + yargs.positional("host", { describe: "Host to stop", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.HostStopRequest()); + }, +})); + +hostCommands.add(new lib.Command({ + definition: ["restart ", "Restart the given host", (yargs) => { + yargs.positional("host", { describe: "Host to restart", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + }, +})); + +hostCommands.add(new lib.Command({ + definition: [["list", "l"], "List hosts connected to the controller"], + handler: async function(args: object, control: Control) { + let hosts = await control.send(new lib.HostListRequest()); + print(asTable(hosts)); + }, +})); + +hostCommands.add(new lib.Command({ + definition: ["generate-token", "Generate token for a host", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Host id" }); + }], + handler: async function(args: { id?: number }, control: Control) { + let hostId = typeof args.id === "number" ? args.id : undefined; + let response = await control.send(new lib.HostGenerateTokenRequest(hostId)); + print(response); + }, +})); + +hostCommands.add(new lib.Command({ + definition: ["revoke-token ", "Revoke all tokens for a host", (yargs) => { + yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.send(new lib.HostRevokeTokensRequest(hostId)); + }, +})); + +hostCommands.add(new lib.Command({ + definition: ["create-config", "Create host config", (yargs) => { + yargs.option("id", { type: "number", nargs: 1, describe: "Host id" }); + yargs.option("name", { type: "string", nargs: 1, describe: "Host name" }); + 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-host.json", + }); + }], + handler: async function( + args: { id?: number, name?: string, generateToken: boolean, output: string }, + control: Control + ) { + let config = await control.send( + new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken) + ); + + let content = JSON.stringify(config, null, "\t"); + if (args.output === "-") { + print(content); + } else { + logger.info(`Writing ${args.output}`); + try { + await fs.outputFile(args.output, content, { flag: "wx" }); + } catch (err: any) { + if (err.code === "EEXIST") { + throw new lib.CommandError(`File ${args.output} already exists`); + } + throw err; + } + } + }, +})); +hostCommands.add(new lib.Command({ + definition: ["update ", "Update the host", (yargs) => { + yargs.positional("host", { describe: "Host to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.HostUpdateRequest()); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Host updated; a restart is required to apply the changes."); + } + }, +})); + + +const hostConfigCommands = new lib.CommandTree({ + name: "config", alias: ["c"], description: "Host config management", +}); +hostConfigCommands.add(new lib.Command({ + definition: ["list ", "List configuration for a host", (yargs) => { + yargs.positional("host", { describe: "Host to list config for", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); + + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); + } + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["set [value]", "Set field in host config", (yargs) => { + yargs.positional("host", { describe: "Host 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: { host: string, field: string, value?: string, stdin?: boolean }, + control: Control, + ) { + let hostId = await lib.resolveHost(control, args.host); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value)); + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["set-prop [value]", "Set property of field in host config", (yargs) => { + yargs.positional("host", { describe: "Host 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: { host: string, field: string, prop: string, value?: string, stdin?: boolean }, + control: Control + ) { + let hostId = await lib.resolveHost(control, args.host); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let value; + try { + if (args.value !== undefined) { + value = JSON.parse(args.value); + } + } catch (err: any) { + // See note for the instance version of set-prop + if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) { + throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + value = args.value; + } + await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value)); + }, +})); + +hostConfigCommands.add(new lib.Command({ + definition: ["edit [editor]", "Edit host configuration", (yargs) => { + yargs.positional("host", { describe: "Host to set config on", type: "string" }); + yargs.positional("editor", { + describe: "Editor to use", + type: "string", + default: "", + }); + }], + handler: async function(args: { host: string, editor: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); + let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === undefined) { + throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl controller config edit "`); + } + let disallowedList = {"host.id": 0}; + let allConfigElements = serializedConfigToString( + config, + lib.HostConfig, + disallowedList + ); + await fs.writeFile(tmpFile, allConfigElements); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + await events.once(editorSpawn, "exit"); + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest( + index, + 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); + } + } + } + try { + await fs.unlink(tmpFile); + } catch (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."); + } + }, +})); + +hostCommands.add(hostConfigCommands); + + +const hostPluginCommands = new lib.CommandTree({ + name: "plugin", alias: ["p"], description: "host plugin inspection", +}); +hostPluginCommands.add(new lib.Command({ + definition: ["list ", "List plugins on a host", (yargs) => { + yargs.positional("host", { describe: "Host to list for", type: "string" }); + }], + handler: async function(args: { host: string }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest()); + print(asTable(plugins.map(p => ({ + title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage, + })))); + }, +})); +hostPluginCommands.add(new lib.Command({ + definition: ["update ", "Update a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to update on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin updated; a restart is required to apply the changes."); + } + }, +})); +hostPluginCommands.add(new lib.Command({ + definition: ["install ", "Install a plugin on a host", (yargs) => { + yargs.positional("host", { describe: "Host to install on", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); + }], + handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) { + let hostId = await lib.resolveHost(control, args.host); + await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin)); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Plugin installed; a restart is required to apply the changes."); + } + }, +})); +hostCommands.add(hostPluginCommands); diff --git a/packages/ctl/src/commands_instance.ts b/packages/ctl/src/commands_instance.ts new file mode 100644 index 00000000..28a4efa4 --- /dev/null +++ b/packages/ctl/src/commands_instance.ts @@ -0,0 +1,687 @@ +import fs from "fs-extra"; +import asTableModule from "as-table"; +import events from "events"; +import path from "path"; +import phin from "phin"; +import os from "os"; +import child_process from "child_process"; +import stream from "stream"; +import util from "util"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; +import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); +const finished = util.promisify(stream.finished); + +export const instanceCommands = new lib.CommandTree({ + name: "instance", alias: ["i"], description: "Instance management", +}); +instanceCommands.add(new lib.Command({ + definition: [["list", "l"], "List instances known to the controller"], + handler: async function(args: object, control: Control) { + let list = await control.send(new lib.InstanceDetailsListRequest()); + print(asTable(list)); + }, +})); + +instanceCommands.add(new lib.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" }, + "from": { type: "number", nargs: 1, describe: "Clone config from instance id" }, + }); + }], + handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) { + let instanceConfig = new lib.InstanceConfig("control"); + if (args.id !== undefined) { + instanceConfig.set("instance.id", args.id); + } + instanceConfig.set("instance.name", args.name); + await control.send(new lib.InstanceCreateRequest( + instanceConfig.toRemote("controller", [ + "instance.id", "instance.name", + ]), + args.from + )); + }, +})); + +const instanceConfigCommands = new lib.CommandTree({ + name: "config", alias: ["c"], description: "Instance config management", +}); +instanceConfigCommands.add(new lib.Command({ + definition: ["list ", "List configuration for an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list config for", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); + } + }, +})); + +instanceConfigCommands.add(new lib.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: { instance: string, field: string, value?: string, stdin?: boolean }, + control: Control, + ) { + let instanceId = await lib.resolveInstance(control, args.instance); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } else if (args.value === undefined) { + args.value = ""; + } + await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value)); + }, +})); + +instanceConfigCommands.add(new lib.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: { instance: string, field: string, prop: string, value?: string, stdin?: boolean }, + control: Control + ) { + let instanceId = await lib.resolveInstance(control, args.instance); + if (args.stdin) { + args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); + } + let value; + try { + if (args.value !== undefined) { + value = JSON.parse(args.value); + } + } catch (err: any) { + // 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: + // ----------------------- WARNING ------------------------------- + // This table is most likely invalid due a change in yargs parsing + // --------------------------------------------------------------- + // 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 lib.CommandError(`In parsing value '${args.value}': ${err.message}`); + } + value = args.value; + } + await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value)); + }, +})); + +instanceConfigCommands.add(new lib.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: { instance: string, editor: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); + let editor = await getEditor(args.editor); + if (editor === undefined) { + throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars + Try "ctl controller config edit "`); + } + let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; + let allConfigElements = serializedConfigToString( + config, + lib.InstanceConfig, + disallowedList + ); + await fs.writeFile(tmpFile, allConfigElements); + let editorSpawn = child_process.spawn(editor, [tmpFile], { + stdio: "inherit", + detached: false, + }); + editorSpawn.on("data", (data) => { + process.stdout.pipe(data); + }); + await events.once(editorSpawn, "exit"); + const data = await fs.readFile(tmpFile, "utf8"); + const final = await configToKeyVal(data); + for (let index in final) { + if (index in final) { + try { + await control.send(new lib.InstanceConfigSetFieldRequest( + instanceId, + index, + 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); + } + } + } + try { + await fs.unlink(tmpFile); + } catch (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 lib.Command({ + definition: ["assign [host]", "Assign instance to a host", (yargs) => { + yargs.positional("instance", { describe: "Instance to assign", type: "string" }); + yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" }); + }], + handler: async function(args: { instance: string, host?: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined; + await control.send(new lib.InstanceAssignRequest(instanceId, hostId)); + }, +})); + +async function loadMapSettings( + args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string}, +) { + let seed = args.seed; + let mapGenSettings; + let mapSettings; + if (args.mapExchangeString) { + let parsed = lib.readMapExchangeString(args.mapExchangeString); + mapGenSettings = parsed.map_gen_settings; + mapSettings = parsed.map_settings; + } + if (args.mapGenSettings) { + mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString()); + } + if (args.mapSettings) { + mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString()); + } + + return { + seed, + mapGenSettings, + mapSettings, + }; +} + +const instanceSaveCommands = new lib.CommandTree({ + name: "save", alias: ["s"], description: "Instance save management", +}); +instanceSaveCommands.add(new lib.Command({ + definition: ["list ", "list saves on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest()); + saves = saves.filter(save => save.instanceId === instanceId); + print(asTable(saves.map( + ({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() }) + ))); + }, +})); + +instanceSaveCommands.add(new lib.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: { + instance: string, + name: string, + seed?: number, + mapExchangeString?: string, + mapGenSettings?: string, + mapSettings?: string, + }, + control: Control + ) { + let instanceId = await lib.resolveInstance(control, args.instance); + let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo( + { instanceId }, + new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings), + ); + }, +})); + +instanceSaveCommands.add(new lib.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: { instance: string, oldName: string, newName: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName)); + }, +})); + +instanceSaveCommands.add(new lib.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: { instance: string, source: string, destination: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination)); + }, +})); + +instanceSaveCommands.add(new lib.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: { instance: string, filepath: string, name?: string }, control: Control) { + let filename = args.name || path.basename(args.filepath); + if (!filename.endsWith(".zip")) { + throw new lib.CommandError("Save name must end with .zip"); + } + // phin doesn't support streaming requests :( + let content = await fs.readFile(args.filepath); + + let instanceId = await lib.resolveInstance(control, args.instance); + let url = new URL(control.config.get("control.controller_url")!); + url.pathname += "api/upload-save"; + url.searchParams.append("instance_id", String(instanceId)); + url.searchParams.append("filename", filename); + + let result = await phin<{ errors?: string[], request_errors?: string[], saves?: string[] }>({ + url, method: "POST", + headers: { + "X-Access-Token": control.config.get("control.controller_token"), + "Content-Type": "application/zip", + }, + core: { ca: control.tlsCa } as object, + 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 lib.CommandError("Uploading save failed"); + } + }, +})); + +instanceSaveCommands.add(new lib.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: { + sourceInstance: string, + sourceSave: string, + targetInstance: string, + targetSave: string, + copy: boolean, + }, + control: Control + ) { + let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance); + let targetInstanceId = await lib.resolveInstance(control, args.targetInstance); + let storedName = await control.send( + new lib.InstanceTransferSaveRequest( + sourceInstanceId, + args.sourceSave, + targetInstanceId, + args.targetSave || args.sourceSave, + args.copy + ) + ); + print(`Transferred as ${storedName} to ${args.targetInstance}.`); + }, +})); + +instanceSaveCommands.add(new lib.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: { instance: string, save: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save)); + + let url = new URL(control.config.get("control.controller_url")!); + url.pathname += `api/stream/${streamId}`; + let response = await phin({ + url, method: "GET", + core: { ca: control.tlsCa } as object, + 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: any) { + if (err.code === "EEXIST") { + tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip"); + } else { + throw err; + } + } + } + response.pipe(writeStream); + await finished(writeStream); + + let filename = await lib.findUnusedName(".", args.save, ".zip"); + await fs.rename(tempFilename, filename); + + logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`); + }, +})); + +instanceSaveCommands.add(new lib.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: { instance: string, save: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save)); + }, +})); +instanceCommands.add(instanceSaveCommands); + +instanceCommands.add(new lib.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: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest()); + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["extract-players ", "Extract players from running save into the cluster.", (yargs) => { + yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest()); + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["start ", "Start instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to start", type: "string" }); + yargs.options({ + "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { + const instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save)); + control.keepOpen = args.keepOpen; + }, +})); +instanceCommands.add(new lib.Command({ + definition: ["restart ", "Restart instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to restart", type: "string" }); + yargs.options({ + "save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) { + const instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save)); + control.keepOpen = args.keepOpen; + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => { + yargs.options({ + "force": { + describe: "Start instances even if they have exclude_from_start_all set", + nargs: 0, + type: "boolean", + default: false, + }, + }); + }], + handler: async function(args: { force: boolean }, control: Control) { + // Get all instances + const instances = await control.send(new lib.InstanceDetailsListRequest()); + + // Filter instances that should be started + const instancesToStart = instances.filter((instance: lib.InstanceDetails) => { + // Only start instances that are currently stopped + if (instance.status !== "stopped") { + return false; + } + + // Respect exclude_from_start_all setting unless --force is used + if (!args.force && instance.excludeFromStartAll) { + return false; + } + + return true; + }); + + if (instancesToStart.length === 0) { + print("No instances to start."); + return; + } + + print(`Starting ${instancesToStart.length} instance(s):`); + for (const instance of instancesToStart) { + print(` - ${instance.name} (ID: ${instance.id})`); + } + + // Start all filtered instances + const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => { + try { + await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest()); + print(`✓ Started ${instance.name}`); + } catch (error) { + print(`✗ Failed to start ${instance.name}: ${error}`); + } + }); + + await Promise.all(startPromises); + print("Start all operation completed."); + }, +})); + +instanceCommands.add(new lib.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: { + instance: string, + scenario: string, + seed?: number, + mapExchangeString?: string, + mapGenSettings?: string, + mapSettings?: string, + keepOpen: boolean, + }, + control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); + await control.sendTo( + { instanceId }, + new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings), + ); + control.keepOpen = args.keepOpen; + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["stop ", "Stop instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to stop", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceStopRequest()); + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["stop-all", "Stop all running instances", () => {}], + handler: async function(_args, control: Control) { + // Get all instances + const instances = await control.send(new lib.InstanceDetailsListRequest()); + + // Filter instances that should be stopped + const instancesToStop = instances + .filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status)); + + if (instancesToStop.length === 0) { + print("No instances to stop."); + return; + } + + print(`Stopping ${instancesToStop.length} instance(s):`); + for (const instance of instancesToStop) { + print(` - ${instance.name} (ID: ${instance.id})`); + } + + // Stop all filtered instances + const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => { + try { + await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest()); + print(`✓ Stopped ${instance.name}`); + } catch (error) { + print(`✗ Failed to stop ${instance.name}: ${error}`); + } + }); + + await Promise.all(stopPromises); + print("Stop all operation completed."); + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["kill ", "Kill instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to kill", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new lib.InstanceKillRequest()); + }, +})); + +instanceCommands.add(new lib.Command({ + definition: ["delete ", "Delete instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to delete", type: "string" }); + }], + handler: async function(args: { instance: string }, control: Control) { + let instanceId = await lib.resolveInstance(control, args.instance); + await control.send(new lib.InstanceDeleteRequest(instanceId)); + }, +})); + +instanceCommands.add(new lib.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: { instance: string, command: string }, control: Control) { + let result = await control.sendTo( + { instanceId: await lib.resolveInstance(control, args.instance) }, + new lib.InstanceSendRconRequest(args.command), + ); + + // Factorio includes a newline in its response output. + process.stdout.write(result); + }, +})); diff --git a/packages/ctl/src/commands_log.ts b/packages/ctl/src/commands_log.ts new file mode 100644 index 00000000..c121b3bc --- /dev/null +++ b/packages/ctl/src/commands_log.ts @@ -0,0 +1,90 @@ +import winston from "winston"; + +import * as lib from "@clusterio/lib"; +import { ConsoleTransport, levels, logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; + +export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" }); +logCommands.add(new lib.Command({ + definition: ["follow", "follow cluster log", (yargs) => { + yargs.options({ + "all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false }, + "controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false }, + "host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null }, + "instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null }, + }); + }], + handler: async function( + args: { all: boolean, controller: boolean, host: string | null, instance: string | null }, + control: Control + ) { + if (!args.all && !args.controller && !args.host && !args.instance) { + logger.error("At least one of --all, --controller, --host and --instance must be passed"); + process.exitCode = 1; + return; + } + let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : []; + await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds }); + control.keepOpen = true; + }, +})); + +logCommands.add(new lib.Command({ + definition: ["query", "Query cluster log", (yargs) => { + yargs.options({ + "all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false }, + "controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false }, + "host": { describe: "Query log of given host", 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: undefined }, + "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: { + all: boolean, + controller: boolean, + host: string | null, + instance: string | null, + maxLevel?: string, + limit: number, + start: boolean, + }, + control: Control + ) { + if (!args.all && !args.controller && !args.host && !args.instance) { + logger.error("At least one of --all, --controller, --host and --instance must be passed"); + process.exitCode = 1; + return; + } + let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : []; + let result = await control.send(new lib.LogQueryRequest( + args.all, + args.controller, + hostIds, + instanceIds, + args.maxLevel as keyof typeof levels, + args.limit, + args.start ? "asc" : "desc", + )); + + if (!args.start) { + result.log.reverse(); + } + + let stdoutLogger = winston.createLogger({ + level: "verbose", + levels, + format: new lib.TerminalFormat({ showTimestamp: true }), + transports: [ + new ConsoleTransport({ errorLevels: [], warnLevels: [] }), + ], + }); + for (let info of result.log) { + stdoutLogger.log(info as any); + } + }, +})); diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts new file mode 100644 index 00000000..d4f9a3ec --- /dev/null +++ b/packages/ctl/src/commands_mod.ts @@ -0,0 +1,241 @@ +import fs from "fs-extra"; +import asTableModule from "as-table"; +import events from "events"; +import path from "path"; +import phin from "phin"; +import stream from "stream"; +import util from "util"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); +const finished = util.promisify(stream.finished); + +export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" }); +modCommands.add(new lib.Command({ + definition: ["show ", "Show details for a mod stored in the cluster", (yargs) => { + yargs.positional("name", { describe: "Mod name to show details for", type: "string" }); + yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); + }], + handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } + let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); + for (let [field, value] of Object.entries(modInfo)) { + if (value instanceof Array) { + print(`${field}:`); + for (let entry of value) { + print(` ${entry}`); + } + } else { + print(`${field}: ${value}`); + } + } + }, +})); + +modCommands.add(new lib.Command({ + definition: [["list", "l"], "List mods stored in the cluster", (yargs) => { + yargs.options({ + "fields": { + describe: "Fields to show, supports 'all'.", + array: true, + type: "string", + default: ["name", "version", "title", "factorio_version"], + }, + }); + }], + handler: async function(args: { fields: string[] }, control: Control) { + let mods = await control.send(new lib.ModListRequest()); + if (!args.fields.includes("all")) { + for (let entry of mods) { + for (let field of Object.keys(entry)) { + if (!args.fields.includes(field)) { + // @ts-expect-error terrible hack + delete entry[field]; + } + } + } + } + print(asTable(mods)); + }, +})); + +modCommands.add(new lib.Command({ + definition: ["search [query]", "Search mods stored in the cluster", (yargs) => { + yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" }); + yargs.positional("query", { describe: "Search query", type: "string", default: "" }); + yargs.options({ + "page": { + describe: "Result page to show", + type: "number", + default: 1, + }, + "page-size": { + describe: "Results per page to show", + type: "number", + default: 10, + }, + "sort": { + describe: "sort results by given field", + type: "string", + }, + "sort-order": { + describe: "order to sort results in (asc/desc)", + type: "string", + default: "asc", + }, + "fields": { + describe: "Fields to show, supports 'all'.", + array: true, + type: "string", + default: ["name", "version", "title", "factorio_version"], + }, + }); + }], + handler: async function( + args: { + factorioVersion: string, + query: string, + page: number, + pageSize: number, + sort?: string, + sortOrder: "asc" | "desc", + fields: string[], + }, + control: Control + ) { + let response = await control.send(new lib.ModSearchRequest( + args.query, + lib.normaliseApiVersion(args.factorioVersion as any), + args.page, + args.pageSize, + args.sort, + args.sortOrder + )); + let results = response.results.flatMap(result => result.versions); + if (!args.fields.includes("all")) { + for (let entry of results) { + for (let field of Object.keys(entry)) { + if (!args.fields.includes(field)) { + // @ts-expect-error terrible hack + delete entry[field]; + } + } + } + } + for (let issue of response.queryIssues) { + print(issue); + } + print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`); + print(asTable(results)); + }, +})); + +modCommands.add(new lib.Command({ + definition: ["upload ", "Upload mod to the cluster", (yargs) => { + yargs.positional("file", { describe: "File to upload", type: "string" }); + }], + handler: async function(args: { file: string }, control: Control) { + let filename = path.basename(args.file); + if (!filename.endsWith(".zip")) { + throw new lib.CommandError("Mod filename must end with .zip"); + } + // phin doesn't support streaming requests :( + let content = await fs.readFile(args.file); + + let url = new URL(control.config.get("control.controller_url")!); + url.pathname += "api/upload-mod"; + url.searchParams.append("filename", filename); + + let result = await phin< + { errors?: [], request_errors?: [], mods: Parameters[0][]} + >({ + url, method: "POST", + headers: { + "X-Access-Token": control.config.get("control.controller_token"), + "Content-Type": "application/zip", + }, + core: { ca: control.tlsCa } as object, + 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.mods && result.body.mods.length) { + const mod = lib.ModInfo.fromJSON(result.body.mods[0]); + logger.info(`Successfully uploaded ${mod.filename}`); + } + + if ((result.body.errors || []).length || (result.body.request_errors || []).length) { + throw new lib.CommandError("Uploading mod failed"); + } + }, +})); + +modCommands.add(new lib.Command({ + definition: ["download ", "Download a mod from the cluster", (yargs) => { + yargs.positional("name", { describe: "Internal name of mod to download", type: "string" }); + yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); + }], + handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } + let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); + + let url = new URL(control.config.get("control.controller_url")!); + url.pathname += `api/stream/${streamId}`; + let response = await phin({ + url, method: "GET", + core: { ca: control.tlsCa } as object, + stream: true, + }); + + let writeStream; + let filename = `${args.name}_${args.modVersion}.zip`; + let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); + while (true) { + try { + writeStream = fs.createWriteStream(tempFilename, { flags: "wx" }); + await events.once(writeStream, "open"); + break; + } catch (err: any) { + if (err.code === "EEXIST") { + tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip"); + } else { + throw err; + } + } + } + response.pipe(writeStream); + await finished(writeStream); + await fs.rename(tempFilename, filename); + + logger.info(`Downloaded ${filename}`); + }, +})); + +modCommands.add(new lib.Command({ + definition: ["delete ", "Delete a mod stored in the cluster", (yargs) => { + yargs.positional("name", { describe: "Name of mod to delete", type: "string" }); + yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); + }], + handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } + await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); + }, +})); diff --git a/packages/ctl/src/commands_mod_pack.ts b/packages/ctl/src/commands_mod_pack.ts new file mode 100644 index 00000000..0374be2c --- /dev/null +++ b/packages/ctl/src/commands_mod_pack.ts @@ -0,0 +1,315 @@ +import asTableModule from "as-table"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" }); +modPackCommands.add(new lib.Command({ + definition: ["show ", "Show details of mod pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" }); + }], + handler: async function(args: { modPack: string }, control: Control) { + let mods = new Map( + (await control.send(new lib.ModListRequest())).map(m => [m.id, m]) + ); + let modPack = await control.send( + new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) + ); + + for (let [field, value] of Object.entries(modPack)) { + if (field === "mods") { + print(`${field}:`); + for (let [name, entry] of value) { + const mod = mods.get(`${name}_${entry.version}`); + const missing = !mod && "? "; + const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! "; + const warn = missing || badChecksum || ""; + const enabled = entry.enabled ? "" : "(disabled) "; + print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); + } + } else if (field === "settings") { + print(`${field}:`); + for (let [scope, settings] of Object.entries(value)) { + print(` ${scope}:`); + for (let [setting, settingValue] of settings as any) { + print(` ${setting}: ${JSON.stringify(settingValue.value)}`); + } + } + } else if (field === "exportManifest") { + print(`${field}:`); + if (value && value.assets) { + print(" assets:"); + for (let [name, fileName] of Object.entries(value.assets)) { + print(` ${name}: ${fileName}`); + } + } + + } else { + print(`${field}: ${value}`); + } + } + }, +})); + +modPackCommands.add(new lib.Command({ + definition: [["list", "l"], "List mod packs in the cluster"], + handler: async function(args: object, control: Control) { + let modPacks = await control.send(new lib.ModPackListRequest()); + let fields = ["id", "name", "factorioVersion"]; + for (let entry of modPacks) { + for (let field of Object.keys(entry)) { + if (!fields.includes(field)) { + // @ts-expect-error terrible hack + delete entry[field]; + } + } + } + print(asTable(modPacks)); + }, +})); + +function setModPackSettings( + modPack: lib.ModPack, + args: { + boolSetting?: string[], + intSetting?: string[], + doubleSetting?: string[], + stringSetting?: string[], + colorSetting?: string[], + }, +) { + function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) { + for (let i = 0; i + 2 < settings.length; i += 3) { + const [scope, name, value] = settings.slice(i, i + 3); + if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { + throw new lib.CommandError( + `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` + ); + } + const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user"; + modPack.settings[checkedScope].set(name, { value: cast(value) }); + } + } + + doSettings(args.boolSetting || [], value => { + if (!["true", "false"].includes(value)) { + throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`); + } + return value === "true"; + }); + doSettings(args.intSetting || [], value => { + // eslint-disable-next-line radix + let number = Number.parseInt(value); + if (isNaN(number)) { + throw new lib.CommandError(`int value must be an integer, not ${value}`); + } + return number; + }); + doSettings(args.doubleSetting || [], value => { + let number = Number.parseFloat(value); + if (isNaN(number)) { + throw new lib.CommandError(`double value must be a number, not ${value}`); + } + return number; + }); + doSettings(args.stringSetting || [], value => value); + doSettings(args.colorSetting || [], value => { + let color = JSON.parse(value); + if (typeof color !== "object" || color === null) { + throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties"); + } + return color; + }); +} + +function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { + for (let mod of mods || []) { + const [name, version, sha1] = mod.split(":"); + if (!version) { + throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); + } + if (!lib.isFullVersion(version)) { + throw new lib.CommandError("version must match the format digit.digit.digit"); + } + if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { + throw new lib.CommandError("sha1 must be a 40 digit lower case hex string"); + } + modPack.mods.set(name, { name, enabled: true, version, sha1 }); + } +} + +function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) { + for (let mod of mods || []) { + if (!modPack.mods.has(mod)) { + throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`); + } + modPack.mods.get(mod)!.enabled = enabled; + } +} + +modPackCommands.add(new lib.Command({ + definition: ["create ", "Create mod pack", (yargs) => { + yargs.positional("name", { describe: "Name of mod pack to create", type: "string" }); + yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" }); + yargs.options({ + "description": { describe: "Description for mod pack", type: "string" }, + "mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" }, + "disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" }, + "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, + "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, + "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, + "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, + "color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" }, + }); + }], + handler: async function( + args: { + name: string, + factorioVersion: string, + description?: string, + mods?: string[], + disabledMods?: string[], + boolSetting?: string[], + intSetting?: string[], + doubleSetting?: string[], + stringSetting?: string[], + colorSetting?: string[], + }, + control: Control, + ) { + const modPack = new lib.ModPack(); + modPack.name = args.name; + if (args.description) { modPack.description = args.description; } + if (args.factorioVersion) { + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); + } + modPack.factorioVersion = args.factorioVersion; + } + setModPackMods(modPack, args.mods); + setModPackModsEnabled(modPack, args.disabledMods, false); + setModPackSettings(modPack, args); + await control.send(new lib.ModPackCreateRequest(modPack)); + print(`Created mod pack ${modPack.name} (${modPack.id})`); + }, +})); + +modPackCommands.add(new lib.Command({ + definition: ["import ", "Import mod pack string", (yargs) => { + yargs.positional("string", { describe: "Mod pack string to import", type: "string" }); + }], + handler: async function(args: { string: string }, control: Control) { + const modPack = lib.ModPack.fromModPackString(args.string); + await control.send(new lib.ModPackCreateRequest(modPack)); + print(`Created mod pack ${modPack.name} (${modPack.id})`); + }, +})); + +modPackCommands.add(new lib.Command({ + definition: ["export ", "Export mod pack string", (yargs) => { + yargs.positional("string", { describe: "Mod pack to export", type: "string" }); + }], + handler: async function(args: { modPack: string }, control: Control) { + const modPack = await control.send( + new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) + ); + print(modPack.toModPackString()); + }, +})); + +modPackCommands.add(new lib.Command({ + definition: ["edit ", "Edit mod pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" }); + yargs.options({ + "name": { describe: "New name for mod pack", type: "string" }, + "description": { describe: "New description for mod pack", type: "string" }, + "factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" }, + "add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" }, + "enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" }, + "disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" }, + "remove-mods": { describe: "Name of mods to remove", array: true, type: "string" }, + "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, + "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, + "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, + "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, + "color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" }, + "remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" }, + }); + }], + handler: async function( + args: { + modPack: string, + name?: string, + description?: string, + factorioVersion?: string, + addMods?: string[], + enableMods?: string[], + disableMods?: string[], + removeMods?: string[], + boolSetting?: string[], + intSetting?: string[], + doubleSetting?: string[], + stringSetting?: string[], + colorSetting?: string[], + removeSetting?: string[], + }, + control: Control, + ) { + const modPack = await control.send( + new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack)) + ); + + if (args.name) { modPack.name = args.name; } + if (args.description) { modPack.description = args.description; } + if (args.factorioVersion) { + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); + } + modPack.factorioVersion = args.factorioVersion; + } + + setModPackMods(modPack, args.addMods); + for (let name of args.removeMods || []) { + if (modPack.mods.has(name)) { + modPack.mods.delete(name); + } else { + logger.warn(`Mod ${name} did not exist on ${modPack.name}`); + } + } + setModPackModsEnabled(modPack, args.disableMods, false); + setModPackModsEnabled(modPack, args.enableMods, true); + + setModPackSettings(modPack, args); + if (args.removeSetting) { + for (let i = 0; i + 1 < args.removeSetting.length; i += 2) { + const [scope, name] = args.removeSetting.slice(i, i + 2); + if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { + throw new lib.CommandError( + `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` + ); + } + const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user"; + if (modPack.settings[checkedScope].has(name)) { + modPack.settings[checkedScope].delete(name); + } else { + logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`); + } + } + } + await control.send(new lib.ModPackUpdateRequest(modPack)); + }, +})); + +modPackCommands.add(new lib.Command({ + definition: ["delete ", "Delete mod pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" }); + }], + handler: async function(args: { modPack: string }, control: Control) { + const id = await lib.resolveModPack(control, args.modPack); + await control.send(new lib.ModPackDeleteRequest(id)); + }, +})); diff --git a/packages/ctl/src/commands_permission.ts b/packages/ctl/src/commands_permission.ts new file mode 100644 index 00000000..94d2fbe4 --- /dev/null +++ b/packages/ctl/src/commands_permission.ts @@ -0,0 +1,16 @@ +import asTableModule from "as-table"; + +import * as lib from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" }); +permissionCommands.add(new lib.Command({ + definition: [["list", "l"], "List permissions in the cluster"], + handler: async function(args: object, control: Control) { + let permissions = await control.send(new lib.PermissionListRequest()); + print(asTable(permissions)); + }, +})); diff --git a/packages/ctl/src/commands_role.ts b/packages/ctl/src/commands_role.ts new file mode 100644 index 00000000..ae99188d --- /dev/null +++ b/packages/ctl/src/commands_role.ts @@ -0,0 +1,100 @@ +import asTableModule from "as-table"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" }); +roleCommands.add(new lib.Command({ + definition: [["list", "l"], "List roles in the cluster"], + handler: async function(args: object, control: Control) { + let roles = await control.send(new lib.RoleListRequest()); + print(asTable(roles)); + }, +})); + +roleCommands.add(new lib.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: { name: string, description: string, permissions: string[] }, control: Control) { + let id = await control.send(new lib.RoleCreateRequest( + args.name, + args.description, + args.permissions, + )); + logger.info(`Created role ID ${id}`); + }, +})); + +roleCommands.add(new lib.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: { + role: string, + name?: string, + description?: string, + setPerms?: string[], + addPerms?: string[], + removePerms?: string[], + grantDefault?: boolean, + }, + control: Control + ) { + let role = await lib.retrieveRole(control, args.role); + + if (args.name !== undefined) { + role.name = args.name; + } + if (args.description !== undefined) { + role.description = args.description; + } + if (args.addPerms) { + for (const perm of args.addPerms) { + role.permissions.add(perm); + } + } + if (args.removePerms) { + for (let perm of args.removePerms) { + role.permissions.delete(perm); + } + } + if (args.setPerms !== undefined) { + role.permissions = new Set(args.setPerms); + } + await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions])); + + if (args.grantDefault) { + await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id)); + } + }, +})); + +roleCommands.add(new lib.Command({ + definition: ["delete ", "Delete role", (yargs) => { + yargs.positional("role", { describe: "Role to delete", type: "string" }); + }], + handler: async function(args: { role: string }, control: Control) { + let role = await lib.retrieveRole(control, args.role); + await control.send(new lib.RoleDeleteRequest(role.id)); + }, +})); diff --git a/packages/ctl/src/commands_user.ts b/packages/ctl/src/commands_user.ts new file mode 100644 index 00000000..656f1f44 --- /dev/null +++ b/packages/ctl/src/commands_user.ts @@ -0,0 +1,304 @@ +import fs from "fs-extra"; +import asTableModule from "as-table"; +import path from "path"; + +import * as lib from "@clusterio/lib"; +import type { Control } from "../ctl"; +import { print } from "./command_ops"; + +const asTable = asTableModule.configure({ delimiter: " | " }); + +export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" }); +userCommands.add(new lib.Command({ + definition: ["show ", "Show details for one user", (yargs) => { + yargs.positional("name", { decribe: "Name of user to show", type: "string" }); + yargs.options({ + "instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { name: string, instanceStats: boolean }, control: Control) { + let user = await control.send(new lib.UserGetRequest(args.name)); + Object.assign(user, user.playerStats); + // @ts-expect-error Terrible hack + delete user.playerStats; + let instanceStats = user.instanceStats; + // @ts-expect-error Terrible hack + delete user.instanceStats; + print(asTable(Object.entries(user).map(([property, value]) => ({ property, value })))); + + if (args.instanceStats) { + let instances = await control.send(new lib.InstanceDetailsListRequest()); + function instanceName(id: number) { + let instance = instances.find(i => i.id === id); + if (instance) { + return instance.name; + } + return ""; + } + for (let [id, playerInstanceStats] of instanceStats || []) { + print(); + print(`Instance ${instanceName(id)} (${id}):`); + print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value })))); + } + } + }, +})); + +userCommands.add(new lib.Command({ + definition: [["list", "l"], "List user in the cluster", (yargs) => { + yargs.options({ + "stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false }, + "attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) { + let users = await control.send(new lib.UserListRequest()); + for (let user of users) { + if (args.stats) { + Object.assign(user, user.playerStats); + } + // @ts-expect-error Terrible hack + delete user.playerStats; + // @ts-expect-error Terrible hack + delete user.isDeleted; + // @ts-expect-error Terrible hack + delete user.banReason; + // @ts-expect-error Terrible hack + delete user.instanceStats; + if (!args.attributes) { + // @ts-expect-error Terrible hack + delete user.isAdmin; + // @ts-expect-error Terrible hack + delete user.isWhitelisted; + // @ts-expect-error Terrible hack + delete user.isBanned; + } + } + print(asTable(users)); + }, +})); + +userCommands.add(new lib.Command({ + definition: ["create ", "Create a user", (yargs) => { + yargs.positional("name", { describe: "Name of user to create", type: "string" }); + }], + handler: async function(args: { name: string }, control: Control) { + await control.send(new lib.UserCreateRequest(args.name)); + }, +})); + +userCommands.add(new lib.Command({ + definition: ["revoke-token ", "Revoke token for user", (yargs) => { + yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" }); + }], + handler: async function(args: { name: string }, control: Control) { + await control.send(new lib.UserRevokeTokenRequest(args.name)); + }, +})); + +userCommands.add(new lib.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: { user: string, revoke: boolean, create: boolean }, control: Control) { + await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke)); + }, +})); + +userCommands.add(new lib.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: { user: string, remove: boolean, create: boolean }, control: Control) { + await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove)); + }, +})); + +userCommands.add(new lib.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: { + user: string, + pardon: boolean, + reason: string, + create: boolean + }, + control: Control + ) { + await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason)); + }, +})); + +userCommands.add(new lib.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: { user: string, roles: string[] }, control: Control) { + let roles = await control.send(new lib.RoleListRequest()); + + 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 roles) { + if (role.name === roleName) { + resolvedRoles.push(role.id); + found = true; + break; + } + } + + if (!found) { + throw new lib.CommandError(`No role named ${roleName}`); + } + } + } + + await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles)); + }, +})); + +userCommands.add(new lib.Command({ + definition: ["delete ", "Delete user", (yargs) => { + yargs.positional("user", { describe: "Name of user to delete", type: "string" }); + }], + handler: async function(args: { user: string }, control: Control) { + await control.send(new lib.UserDeleteRequest(args.user)); + }, +})); + +async function handleImportOrRestore(args: { + filepath: string, + users: boolean, + bans: boolean, + admins: boolean, + whitelist: boolean, + noBackup?: boolean, +}, control: Control, restore: boolean) { + let importType: ConstructorParameters[0]; + const data = JSON.parse((await fs.readFile(args.filepath)).toString()); + const optionCount = [args.users, args.bans, args.admins, args.whitelist] + .reduce((acc, bool) => (bool ? acc + 1 : acc), 0); + + if (optionCount > 1) { + throw new lib.CommandError("Can not specify multiple options"); + } + + // Assign based on options or attempt to guess based on filename + const filename = path.basename(args.filepath); + if (args.users || (optionCount === 0 && data.export_version)) { + importType = "users"; + } else if (args.bans || (optionCount === 0 && filename.includes("ban"))) { + importType = "bans"; + } else if (args.admins || (optionCount === 0 && filename.includes("admin"))) { + importType = "admins"; + } else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) { + importType = "whitelist"; + } else { + throw new lib.CommandError("Unknown json file, please specify an option"); + } + + const backup = await control.send( + new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore) + ); + + print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`); + if (restore && backup && !args.noBackup) { + await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 }); + print(`Wrote backup to ${importType}-backup.json`); + } +} + +userCommands.add(new lib.Command({ + definition: ["import ", "Import user data from a file", (yargs) => { + yargs.positional("filepath", { describe: "Path to the file to import", type: "string" }); + yargs.options({ + "users": { describe: "Import users json", nargs: 0, type: "boolean", default: false }, + "bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false }, + "admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false }, + "whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: any, control: any) { + await handleImportOrRestore(args, control, false); + }, +})); + +userCommands.add(new lib.Command({ + definition: ["restore ", "Restore user data from a file", (yargs) => { + yargs.positional("filepath", { describe: "Path to the file to import", type: "string" }); + yargs.options({ + "users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false }, + "bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false }, + "admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false }, + "whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false }, + "no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: any, control: any) { + await handleImportOrRestore(args, control, true); + }, +})); + +userCommands.add(new lib.Command({ + definition: ["export ", "Export user data to a file", (yargs) => { + yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" }); + yargs.options({ + "users": { describe: "Export users json", nargs: 0, type: "boolean", default: false }, + "bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false }, + "admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false }, + "whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args: { + filepath: string, + users: boolean, + bans: boolean, + admins: boolean, + whitelist: boolean, + }, control: Control) { + let exportType = "users" as ConstructorParameters[0]; + const optionCount = [args.bans, args.admins, args.whitelist] + .reduce((acc, bool) => (bool ? acc + 1 : acc), 0); + + if (optionCount > 1) { + throw new lib.CommandError("Can not specify multiple options"); + } + + // Assign based on options or attempt to guess based on filename + if (args.users) { + exportType = "users"; + } else if (args.bans) { + exportType = "bans"; + } else if (args.admins) { + exportType = "admins"; + } else if (args.whitelist) { + exportType = "whitelist"; + } + + await fs.writeJSON(args.filepath, await control.send( + new lib.UserBulkExportRequest(exportType) + ), { spaces: 2 }); + print(`Exported ${exportType} to ${args.filepath}`); + }, +})); diff --git a/packages/ctl/src/config_ops.ts b/packages/ctl/src/config_ops.ts new file mode 100644 index 00000000..bf0e8a4b --- /dev/null +++ b/packages/ctl/src/config_ops.ts @@ -0,0 +1,58 @@ +import * as lib from "@clusterio/lib"; + +export async function getEditor(argsEditor: string) { + // eslint-disable-next-line + return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined + // needed for the process.env statements to not be flagged by eslint + // priority for editors is CLI argument > env.EDITOR > env.VISUAL +} + +export async function configToKeyVal(data: string) { + let final: Record = {}; + 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) { + let split = 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 = split[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; +} + +export function serializedConfigToString( + serializedConfig: lib.ConfigSchema, + configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig, + disallowedList: Record, +) { + let allConfigElements = ""; + for (let [name, value] of Object.entries(serializedConfig)) { + if (name in disallowedList) { + continue; + } + let desc = ""; + try { + desc += (configGroup.fieldDefinitions as any)[name]!.description; + } catch (err) { + desc += "No description found"; + } + // split onto two lines for readability and es-lint + if (String(value) === "null") { + value = ""; + } + allConfigElements += `${name} = ${value}\n\n`; + } + return allConfigElements; +}