From 7ff2092547722537d52effd8c7ae09b6b8ca625d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:40:32 +0000 Subject: [PATCH 1/3] Add update all for the controller, hosts and plugins Updating a single plugin can leave the installation with multiple versions of @clusterio/lib. Passing the core package and every npm installed plugin to a single npm update call lets npm resolve them together. UpdateAllRequest targets the controller or a host, requires both the core update permission and core.plugin.update, and is gated by both allow_remote_updates and allow_plugin_updates. The web UI Updates modal gets an Update All action and ctl gets --all on the update commands. Closes #910 Co-Authored-By: Claude Fable 5.1 --- docs/managing-a-cluster.md | 7 +++ packages/controller/src/ControlConnection.ts | 11 ++++ packages/ctl/src/commands_controller.ts | 9 +++- packages/ctl/src/commands_host.ts | 9 +++- packages/host/src/Host.ts | 11 ++++ packages/lib/src/data/messages_plugin.ts | 13 +++++ packages/lib/src/link/messages.ts | 1 + packages/lib/src/rce_ops.ts | 11 +++- packages/web_ui/src/components/NpmButton.tsx | 24 ++++++++- test/lib/rce_ops.js | 14 +++++ test/messages/plugin.test.js | 55 ++++++++++++++++++++ 11 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 476d1314..aa3e8587 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -247,6 +247,13 @@ This command lists all plugins currently known to the remote. Newly installed pl If remote plugin updates are enabled on the remote, this will execute `npm update --save `. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete. +### Update All + + ctl> controller update --all [--restart] + ctl> host update --all [--restart] + +Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote. + ### Install ctl> controller plugin install [--restart] diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index c9c9f8b0..865c3ba9 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -128,6 +128,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this)); this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); + this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } @@ -1307,6 +1308,16 @@ export default class ControlConnection extends BaseConnection { return await lib.handlePluginInstall(request.pluginPackage); } + async handleUpdateAllRequest(request: lib.UpdateAllRequest) { + if (!this._controller.config.get("controller.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + if (!this._controller.config.get("controller.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos); + } + async handlePluginListRequest(request: lib.PluginListRequest) { return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts index fbdde7a4..c7e73ba3 100644 --- a/packages/ctl/src/commands_controller.ts +++ b/packages/ctl/src/commands_controller.ts @@ -28,9 +28,14 @@ controllerCommands.add(new lib.Command({ controllerCommands.add(new lib.Command({ definition: ["update", "Update the controller", (yargs) => { 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: { restart: boolean }, control: Control) { - await control.send(new lib.ControllerUpdateRequest()); + handler: async function(args: { restart: boolean, all: boolean }, control: Control) { + if (args.all) { + await control.send(new lib.UpdateAllRequest()); + } else { + await control.send(new lib.ControllerUpdateRequest()); + } if (args.restart) { await control.send(new lib.ControllerRestartRequest()); } else { diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index c44db2fe..82240ae9 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -101,10 +101,15 @@ 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" }); + yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" }); }], - handler: async function(args: { host: string, restart: boolean }, control: Control) { + handler: async function(args: { host: string, restart: boolean, all: boolean }, control: Control) { let hostId = await lib.resolveHost(control, args.host); - await control.sendTo({ hostId }, new lib.HostUpdateRequest()); + if (args.all) { + await control.sendTo({ hostId }, new lib.UpdateAllRequest()); + } else { + await control.sendTo({ hostId }, new lib.HostUpdateRequest()); + } if (args.restart) { await control.sendTo({ hostId }, new lib.HostRestartRequest()); } else { diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index c97ca6fc..80003dd0 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -403,6 +403,7 @@ export default class Host extends lib.Link { this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this)); this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); + this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); @@ -1151,6 +1152,16 @@ export default class Host extends lib.Link { return await lib.handlePluginInstall(request.pluginPackage); } + async handleUpdateAllRequest(request: lib.UpdateAllRequest) { + if (!this.config.get("host.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + if (!this.config.get("host.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos); + } + async handlePluginListRequest(request: lib.PluginListRequest) { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index edbe7664..e2c9f02c 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -1,6 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; import { jsonArray } from "./composites"; import { PluginNodeEnvInfo } from "../plugin"; +import { Address, MessageRequest } from "./messages_core"; +import type { IUser } from "./UserDetails"; /* This is similar to other plugin definitions but with lots removed */ export class PluginDetails { @@ -91,3 +93,14 @@ export class PluginInstallRequest { return this.pluginPackage; } } + +export class UpdateAllRequest { + declare ["constructor"]: typeof UpdateAllRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission(user: IUser, message: MessageRequest) { + user.checkPermission(message.dst.type === Address.host ? "core.host.update" : "core.controller.update"); + user.checkPermission("core.plugin.update"); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 1db48338..cb2aaee4 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -140,4 +140,5 @@ export const dataClasses: (RequestClass | EventClass) plugin.PluginListRequest, plugin.PluginUpdateRequest, plugin.PluginInstallRequest, + plugin.UpdateAllRequest, ]; diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index b5ad5be5..759312a4 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -19,8 +19,8 @@ async function logExec(cmd: string) { } } -export async function updatePackage(name: string) { - return logExec(`npm update --save ${name}`); +export async function updatePackage(...names: string[]) { + return logExec(`npm update --save ${names.join(" ")}`); } export async function installPackage(name: string) { @@ -35,6 +35,13 @@ export async function handlePluginUpdate(pluginName: string, pluginInfos: Plugin return await updatePackage(pluginName); } +export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) { + const pluginPackages = pluginInfos + .map(plugin => plugin.npmPackage) + .filter((name): name is string => name !== undefined); + return await updatePackage(corePackage, ...pluginPackages); +} + export async function handlePluginInstall(pluginName: string) { if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) { // https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 1a5a83e2..17f1883c 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -27,6 +27,17 @@ function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { ; } +function NpmUpdateAll({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + await control.sendTo(target, new lib.UpdateAllRequest()); + }); + + return <> + ; +} + function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { const control = useContext(ControlContext); const [plugins, setPlugins] = useState([]); @@ -128,6 +139,8 @@ export function NpmButton(props: { } }, [hostId]); + const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update`; + function onValuesChange({ action } : { action?: string }) { if (action) { setFormAction(action); @@ -175,10 +188,17 @@ export function NpmButton(props: {
- {account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`) + {account.hasPermission(coreUpdatePermission) ? Update Clusterio : undefined} + {account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update") + ? + Update All + : undefined} {account.hasPermission("core.plugin.update") ? Update Plugin @@ -198,6 +218,8 @@ export function NpmButton(props: { } {formAction === "remote_update" ? : undefined} + {formAction === "update_all" + ? : undefined} {formAction === "plugin_update" ? : undefined} {formAction === "plugin_install" diff --git a/test/lib/rce_ops.js b/test/lib/rce_ops.js index c5b272b7..4f503ad4 100644 --- a/test/lib/rce_ops.js +++ b/test/lib/rce_ops.js @@ -29,6 +29,20 @@ describe("rce_ops", function() { }); }); + describe("handleUpdateAll", function() { + it("updates the core package and all npm plugins in one command", async function() { + const _audit = lib.logger.audit; + const messages = []; + lib.logger.audit = (msg) => { messages.push(msg); }; + try { + await lib.handleUpdateAll("core", [{ npmPackage: "foo" }, { name: "local" }, { npmPackage: "bar" }]); + } finally { + lib.logger.audit = _audit; + } + assert.deepEqual(messages, ["RCE | npm update --save core foo bar"]); + }); + }); + describe("handlePluginInstall", function() { const _fetch = global.fetch; before(function() { diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index e2c7d9d8..ef2ba9c2 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -134,6 +134,61 @@ describe("messages/plugin", function() { }); }); + describe("UpdateAllRequest", function() { + function checkedPermissions(dst) { + const permissions = []; + const user = { checkPermission(permission) { permissions.push(permission); } }; + lib.UpdateAllRequest.permission(user, { dst: lib.Address.fromShorthand(dst) }); + return permissions; + } + it("requires controller and plugin update permissions on the controller", function() { + assert.deepEqual(checkedPermissions("controller"), ["core.controller.update", "core.plugin.update"]); + }); + it("requires host and plugin update permissions on a host", function() { + assert.deepEqual(checkedPermissions({ hostId: 1 }), ["core.host.update", "core.plugin.update"]); + }); + it("runs on the controller", async function() { + controller.pluginInfos = [{ npmPackage: "foo" }]; + controller.config.set("controller.allow_remote_updates", true); + controller.config.set("controller.allow_plugin_updates", true); + await controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the controller", async function() { + controller.config.set("controller.allow_remote_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the controller", async function() { + controller.config.set("controller.allow_plugin_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + it("runs on a host", async function() { + host.pluginInfos = [{ npmPackage: "foo" }]; + host.config.set("host.allow_remote_updates", true); + host.config.set("host.allow_plugin_updates", true); + await host.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the host", async function() { + host.config.set("host.allow_remote_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the host", async function() { + host.config.set("host.allow_plugin_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + }); + describe("PluginInstallRequest", function() { const _fetch = global.fetch; before(function() { From 637ec0f901d83ee59401a29237e3883331741bbf Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:51:13 +0000 Subject: [PATCH 2/3] Cover the --all flag on the ctl update commands Co-Authored-By: Claude Fable 5.1 --- test/integration/clusterio.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 26d8dfa9..57fe98b7 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -522,6 +522,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("controller update"); }); + it("accepts --all", async function() { + await execCtl("controller update --all"); + }); it("accepts --restart", async function() { // We cannot restart the controller, so we check for controller error instead await assert.rejects( @@ -604,6 +607,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("host update 4"); }); + it("accepts --all", async function() { + await execCtl("host update 4 --all"); + }); it("accepts --restart", async function() { // We cannot restart the host, so we check for host error instead await assert.rejects( From c2325a8b6e06be219b89a576c17a6026ac9bfeba Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:12:49 +0100 Subject: [PATCH 3/3] Add missing `as const` for permission string --- packages/web_ui/src/components/NpmButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 17f1883c..e543cb43 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -139,7 +139,7 @@ export function NpmButton(props: { } }, [hostId]); - const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update`; + const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const; function onValuesChange({ action } : { action?: string }) { if (action) {