Files
factorio-clusterio/packages/ctl/src/commands_host.ts
T
BastiaanandClaude Opus 5.5 839f9eb56c Use the config access API in the ctl config editor (#1059)
serializedConfigToString now asks a control-located config instance
whether each field is readable and writable instead of relying on a
hard-coded list per command. Hidden and unknown fields are skipped, and
field descriptions are written as comments above each value.

Object fields such as factorio.settings are now included, so they are
written as JSON, and configToKeyVal splits on the first = only so those
values survive the round trip.

Fixes #1051

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:53:06 +01:00

311 lines
12 KiB
TypeScript

import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["mark-restart-required <host>", "Mark the given host as requiring a restart", (yargs) => {
yargs.positional("host", { describe: "Host to mark", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, ctl: Ctl) {
let hosts = await ctl.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 }, ctl: Ctl) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.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 },
ctl: Ctl
) {
let config = await ctl.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.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
} 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 <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
handler: async function(args: { host: string, restart: boolean, all: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
if (args.all) {
await ctl.sendTo({ hostId }, new lib.UpdateAllRequest());
} else {
await ctl.sendTo({ hostId }, new lib.HostUpdateRequest());
}
if (args.restart) {
await ctl.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 <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.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 <host> <field> [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 },
ctl: Ctl,
) {
let hostId = await lib.resolveHost(ctl, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <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 },
ctl: Ctl
) {
let hostId = await lib.resolveHost(ctl, 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 ctl.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [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 }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.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 host config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(config, lib.HostConfig);
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 ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
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.");
}
},
}));
hostCommands.add(hostConfigCommands);
const hostPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "host plugin inspection",
});
hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
const plugins = await ctl.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 <host> <plugin>", "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 }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "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 }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
hostCommands.add(hostPluginCommands);