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 (!/^\d+\.\d+\.\d+$/.test(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 (!/^\d+\.\d+\.\d+?$/.test(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 (!/^\d+\.\d+\.\d+?$/.test(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) { 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, args.factorioVersion, 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) { 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) { 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; }, })); export async function registerCommands(ctlPlugins: Map, yargs: Argv) { const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); rootCommands.add(controllerCommands); rootCommands.add(hostCommands); rootCommands.add(instanceCommands); rootCommands.add(modPackCommands); rootCommands.add(modCommands); rootCommands.add(permissionCommands); rootCommands.add(roleCommands); rootCommands.add(userCommands); rootCommands.add(logCommands); rootCommands.add(debugCommands); for (let controlPlugin of ctlPlugins.values()) { await controlPlugin.addCommands(rootCommands); } for (let [name, command] of rootCommands.subCommands) { if (name === command.name) { command.register(yargs); } } return rootCommands; }