import fs from "node:fs/promises"; import asTableModule from "as-table"; import events from "events"; import path from "path"; 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 { Ctl } from "../ctl.js"; import { print } from "./command_ops.js"; import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js"; 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", (yargs) => { yargs.options({ "templates": { describe: "Include templates", nargs: 0, type: "boolean", default: false }, }); }], handler: async function(args: { templates: boolean }, ctl: Ctl) { let list = await ctl.send(new lib.InstanceDetailsListRequest()); print(asTable(list.filter(instance => args.templates || !instance.isTemplate))); }, })); 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" }, "template": { type: "string", nargs: 1, describe: "Template to inherit config from" }, "as-template": { type: "boolean", nargs: 0, describe: "Create the instance as a template" }, }); }], handler: async function( args: { name: string, id?: number, from?: number, template?: string, asTemplate?: boolean }, ctl: Ctl, ) { let instanceConfig = new lib.InstanceConfig("control"); let fields: (keyof lib.InstanceConfigFields)[] = ["instance.id", "instance.name"]; if (args.id !== undefined) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); if (args.template !== undefined) { instanceConfig.set("instance.template_id", await lib.resolveInstance(ctl, args.template)); fields.push("instance.template_id"); } if (args.asTemplate) { instanceConfig.set("instance.is_template", true); fields.push("instance.is_template"); } await ctl.send(new lib.InstanceCreateRequest( instanceConfig.toRemote("controller", fields), 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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId)); const inherited = config["instance.template_id"] !== null ? config["instance.inherited_fields"] as Record ?? {} : {}; for (let [name, value] of Object.entries(config)) { if (name === "instance.inherited_fields") { continue; } const entry = inherited[name]; let note = ""; if (entry instanceof Array && entry.length) { note = ` (inherited except ${entry.join(", ")})`; } else if (entry) { note = " (inherited)"; } print(`${name} ${JSON.stringify(value)}${note}`); } }, })); instanceConfigCommands.add(new lib.Command({ definition: [ "inherit [prop]", "Make field or property in instance config use the value of its template", (yargs) => { yargs.positional("instance", { describe: "Instance to update config on", type: "string" }); yargs.positional("field", { describe: "Field to inherit", type: "string" }); yargs.positional("prop", { describe: "Property of object field to inherit", type: "string" }); }, ], handler: async function(args: { instance: string, field: string, prop?: string }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.send(new lib.InstanceConfigInheritFieldRequest(instanceId, args.field, args.prop)); }, })); 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 }, ctl: Ctl, ) { let instanceId = await lib.resolveInstance(ctl, args.instance); if (args.stdin) { args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); } else if (args.value === undefined) { args.value = ""; } await ctl.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 }, ctl: Ctl ) { let instanceId = await lib.resolveInstance(ctl, 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 ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let config = await ctl.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 instance config edit "`); } let allConfigElements = serializedConfigToString(config, lib.InstanceConfig); 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); const original = await configToKeyVal(allConfigElements); for (let index in final) { // Unchanged fields are skipped so they keep inheriting from the template if (index in final && final[index] !== original[index]) { try { await ctl.send(new lib.InstanceConfigSetFieldRequest( instanceId, 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.`); // If the string is empty, it's better to just print "" instead of nothing 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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let hostId = args.host ? await lib.resolveHost(ctl, args.host) : undefined; await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let saves = await ctl.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, }, ctl: Ctl ) { let instanceId = await lib.resolveInstance(ctl, args.instance); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.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 }, ctl: Ctl) { let filename = args.name || path.basename(args.filepath); if (!filename.endsWith(".zip")) { throw new lib.CommandError("Save name must end with .zip"); } let instanceId = await lib.resolveInstance(ctl, args.instance); let url = new URL(ctl.config.get("ctl.controller_url")!); url.pathname += "api/upload-save"; url.searchParams.append("instance_id", String(instanceId)); url.searchParams.append("filename", filename); const response = await fetch(url, { method: "POST", body: (await fs.open(args.filepath)).createReadStream(), duplex: "half", headers: { "X-Access-Token": ctl.config.get("ctl.controller_token")!, "Content-Type": "application/zip", }, }); type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] }; const result = await response.json() as ResponseType; for (let error of result.errors || []) { logger.error(error); } for (let requestError of result.request_errors || []) { logger.error(requestError); } if (result.saves && result.saves.length) { logger.info(`Successfully uploaded as ${result.saves[0]}`); } if ((result.errors || []).length || (result.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, }, ctl: Ctl ) { let sourceInstanceId = await lib.resolveInstance(ctl, args.sourceInstance); let targetInstanceId = await lib.resolveInstance(ctl, args.targetInstance); let storedName = await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); let streamId = await ctl.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save)); let url = new URL(ctl.config.get("ctl.controller_url")!); url.pathname += `api/stream/${streamId}`; const filename = await lib.downloadFile(url, args.save, "rename"); 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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.sendTo({ instanceId }, new lib.InstanceExportDataRequest()); }, })); instanceCommands.add(new lib.Command({ definition: ["save-game ", "Save the running game of an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to save the game on", type: "string" }); }], handler: async function(args: { instance: string }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.sendTo({ instanceId }, new lib.InstanceSaveGameRequest()); }, })); 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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.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 }, ctl: Ctl) { const instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save)); ctl.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 }, ctl: Ctl) { const instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save)); ctl.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 }, ctl: Ctl) { // Get all instances const instances = await ctl.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 ctl.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, }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); await ctl.sendTo( { instanceId }, new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings), ); ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.sendTo({ instanceId }, new lib.InstanceStopRequest()); }, })); instanceCommands.add(new lib.Command({ definition: ["stop-all", "Stop all running instances", () => {}], handler: async function(_args, ctl: Ctl) { // Get all instances const instances = await ctl.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 ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.setLogSubscriptions({ instanceIds: [instanceId] }); await ctl.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 }, ctl: Ctl) { let instanceId = await lib.resolveInstance(ctl, args.instance); await ctl.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 }, ctl: Ctl) { let result = await ctl.sendTo( { instanceId: await lib.resolveInstance(ctl, args.instance) }, new lib.InstanceSendRconRequest(args.command), ); // Factorio includes a newline in its response output. process.stdout.write(result); }, }));