From ff35b5c378d753433e3fd5f4c995dbc11114959a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 29 Apr 2025 01:40:22 +0100 Subject: [PATCH] Basic functionality of remote npm --- packages/controller/src/ControlConnection.ts | 31 ++++ packages/create/create.js | 24 +++ packages/ctl/src/commands.ts | 88 ++++++++++ packages/host/src/Host.ts | 31 ++++ packages/lib/index.ts | 1 + packages/lib/src/config/definitions.ts | 48 +++++ packages/lib/src/data/messages_controller.ts | 52 ++++++ packages/lib/src/link/messages.ts | 3 + packages/lib/src/permissions.ts | 16 ++ packages/lib/src/rce_ops.ts | 26 +++ .../web_ui/src/components/ControllerPage.tsx | 5 + .../web_ui/src/components/HostViewPage.tsx | 5 + packages/web_ui/src/components/NpmButton.tsx | 166 ++++++++++++++++++ 13 files changed, 496 insertions(+) create mode 100644 packages/lib/src/rce_ops.ts create mode 100644 packages/web_ui/src/components/NpmButton.tsx diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 92db23a5..a2b81ac5 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -119,6 +119,9 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this)); this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this)); this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this)); + this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); + this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); + this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); } @@ -1049,6 +1052,34 @@ export default class ControlConnection extends BaseConnection { return usersToSend; } + async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + if (!this._controller.config.get("controller.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + return lib.updatePackage("@clusterio/controller"); + } + + async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { + if (!this._controller.config.get("controller.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + if (!this._controller.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { + throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); + } + return lib.updatePackage(request.pluginPackage); + } + + async handlePluginInstallRequest(request: lib.PluginInstallRequest) { + if (!this._controller.config.get("controller.allow_plugin_install")) { + throw new lib.RequestError("Plugin installs are disabled on this machine"); + } + const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); + if (!npmRequest.ok) { + throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); + } + return lib.installPackage(request.pluginPackage); + } + async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) { this.ws_dumper = data => { if (this.connector.connected) { diff --git a/packages/create/create.js b/packages/create/create.js index c61cd6b1..de5413b5 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -688,6 +688,18 @@ async function inquirerMissingArgs(args) { } } + if (["standalone", "controller", "host"].includes(answers.mode)) { + answers = inquirer.prompt([ + { + type: "confirm", + name: "remoteNpm", + // TODO maybe link remote install to this once it isn't free text + message: "Allow remote updates via npm?", + default: true, + }, + ], answers); + } + if (answers.mode === "plugin-template") { if (args["plugin-template"] && args["plugin-template"].length > 0) { answers.pluginTemplate = args["plugin-template"]; @@ -824,6 +836,9 @@ async function main() { .option("factorio-dir", { nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string", }) + .option("remote-npm", { + nargs: 0, description: "Allow remote usage of npm [standalone/controller/host]", type: "boolean", + }) .option("plugins", { array: true, describe: "Plugins to install", type: "string", }) @@ -889,6 +904,9 @@ async function main() { logger.info("Setting up controller"); await execController(["bootstrap", "create-admin", answers.admin]); await execController(["config", "set", "controller.http_port", answers.httpPort]); + await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]); + await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]); + // await execController(["config", "set", "controller.allow_plugin_install", answers.remoteNpm]); let result = await execController(["bootstrap", "generate-user-token", answers.admin]); adminToken = result.stdout.split("\n").slice(-2)[0]; } @@ -908,6 +926,9 @@ async function main() { await execHost(["config", "set", "host.controller_token", hostToken]); await execHost(["config", "set", "host.public_address", answers.publicAddress]); await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]); + await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]); + await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]); + // await execHost(["config", "set", "host.allow_plugin_install", answers.remoteNpm]); } if (answers.mode === "host") { @@ -919,6 +940,9 @@ async function main() { await execHost(["config", "set", "host.controller_token", answers.controllerToken]); await execHost(["config", "set", "host.public_address", answers.publicAddress]); await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]); + await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]); + await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]); + // await execHost(["config", "set", "host.allow_plugin_install", answers.remoteNpm]); } if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index efe86c4e..836bb2a8 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -95,6 +95,47 @@ controllerCommands.add(new lib.Command({ 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.sendTo("controller", new lib.RemoteUpdateRequest()); + if (args.restart) { + await control.send(new lib.ControllerRestartRequest()); + } else { + print("Controller updated; a restart is required to apply the changes."); + } + }, +})); +controllerCommands.add(new lib.Command({ + definition: ["update-plugin", "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."); + } + }, +})); +controllerCommands.add(new lib.Command({ + definition: ["install-plugin", "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."); + } + }, +})); const controllerConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "controller config management", @@ -322,6 +363,53 @@ hostCommands.add(new lib.Command({ } }, })); +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.RemoteUpdateRequest()); + if (args.restart) { + await control.sendTo({ hostId }, new lib.HostRestartRequest()); + } else { + print("Host updated; a restart is required to apply the changes."); + } + }, +})); +hostCommands.add(new lib.Command({ + definition: ["update-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 }, 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."); + } + }, +})); +hostCommands.add(new lib.Command({ + definition: ["install-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 }, 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."); + } + }, +})); const hostConfigCommands = new lib.CommandTree({ name: "config", alias: ["c"], description: "Host config management", diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 0e60d2a1..e2450007 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -392,6 +392,9 @@ export default class Host extends lib.Link { this.handle(lib.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this)); this.handle(lib.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this)); this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); + this.handle(lib.RemoteUpdateRequest, this.handleRemoteUpdateRequest.bind(this)); + this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); + this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); } async loadPlugins() { @@ -1043,6 +1046,34 @@ export default class Host extends lib.Link { await fs.remove(instanceInfo.path); } + async handleRemoteUpdateRequest(request: lib.RemoteUpdateRequest) { + if (!this.config.get("host.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + return lib.updatePackage("@clusterio/host"); + } + + async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) { + if (!this.config.get("host.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + if (!this.pluginInfos.some(plugin => plugin.npmPackage === request.pluginPackage)) { + throw new lib.RequestError(`Plugin ${request.pluginPackage} is not installed on this machine`); + } + return lib.updatePackage(request.pluginPackage); + } + + async handlePluginInstallRequest(request: lib.PluginInstallRequest) { + if (!this.config.get("host.allow_plugin_install")) { + throw new lib.RequestError("Plugin installs are disabled on this machine"); + } + const npmRequest = await fetch(`https://www.npmjs.com/package/${encodeURI(request.pluginPackage)}`); + if (!npmRequest.ok) { + throw new lib.RequestError(`Unknown plugin: ${request.pluginPackage}`); + } + return lib.installPackage(request.pluginPackage); + } + sendHostUpdate() { this.send( new lib.HostInfoUpdateEvent( diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 9cd87213..d5ebd73e 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -30,6 +30,7 @@ export * from "./src/zip_ops"; export * from "./src/subscriptions"; export * from "./src/datastore"; export * from "./src/load_plugin_list"; +export * from "./src/rce_ops"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b49e2a0a..5a06c228 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -28,6 +28,9 @@ export interface ControllerConfigFields { "controller.autosave_interval": number; "controller.mod_portal_cache_duration_minutes": number; "controller.mod_portal_page_size": number; + "controller.allow_remote_updates": boolean; + "controller.allow_plugin_updates": boolean; + "controller.allow_plugin_install": boolean; } /** @@ -211,6 +214,27 @@ export class ControllerConfig extends classes.Config { type: "number", initialValue: 1000, }, + "controller.allow_remote_updates": { + description: "When true, allows a remote event to trigger a clusterio update via npm", + type: "boolean", + initialValue: true, + readonly: ["controller"], + hidden: true, + }, + "controller.allow_plugin_updates": { + description: "When true, allows a remote event to trigger a plugin update via npm", + type: "boolean", + initialValue: true, + readonly: ["controller"], + hidden: true, + }, + "controller.allow_plugin_install": { + description: "When true, allows a remote event to trigger a plugin install via npm", + type: "boolean", + initialValue: false, + readonly: ["controller"], + hidden: true, + }, }; } @@ -228,6 +252,9 @@ export interface HostConfigFields { "host.factorio_username": string | null, "host.factorio_token": string | null, "host.max_reconnect_delay": number; + "host.allow_remote_updates": boolean; + "host.allow_plugin_updates": boolean; + "host.allow_plugin_install": boolean; } /** @@ -323,6 +350,27 @@ export class HostConfig extends classes.Config { type: "number", initialValue: 60, }, + "host.allow_remote_updates": { + description: "When true, allows a remote event to trigger a clusterio update via npm", + type: "boolean", + initialValue: true, + readonly: ["host"], + hidden: true, + }, + "host.allow_plugin_updates": { + description: "When true, allows a remote event to trigger a plugin update via npm", + type: "boolean", + initialValue: true, + readonly: ["host"], + hidden: true, + }, + "host.allow_plugin_install": { + description: "When true, allows a remote event to trigger a plugin install via npm", + type: "boolean", + initialValue: false, + readonly: ["host"], + hidden: true, + }, }; } diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index cd51704a..7d7f40d2 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -352,6 +352,58 @@ export class SystemInfoUpdateEvent { } } +export class RemoteUpdateRequest { + declare ["constructor"]: typeof RemoteUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.remote_update"; +} + +export class PluginUpdateRequest { + declare ["constructor"]: typeof PluginUpdateRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.plugin_update"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} + +export class PluginInstallRequest { + declare ["constructor"]: typeof PluginInstallRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission = "core.rce.plugin_install"; + + constructor( + public pluginPackage: string, + ) {} + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.pluginPackage; + } +} + export class DebugDumpWsRequest { declare ["constructor"]: typeof DebugDumpWsRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 200e6c79..4e800b47 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -30,6 +30,9 @@ export const dataClasses: (RequestClass | EventClass) controller.LogMessageEvent, controller.SystemInfoRequest, controller.SystemInfoUpdateEvent, + controller.RemoteUpdateRequest, + controller.PluginUpdateRequest, + controller.PluginInstallRequest, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index c347e23e..27ff3263 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -525,6 +525,22 @@ definePermission({ description: "Query past entries in the cluster log. Required to see past entries in instance console.", }); +definePermission({ + name: "core.rce.remote_update", + title: "Clusterio remote update", + description: "Start a remote update of the clusterio packages", +}); +definePermission({ + name: "core.rce.plugin_update", + title: "Plugin remote update", + description: "Start a remote update of a third party plugin package", +}); +definePermission({ + name: "core.rce.plugin_install", + title: "Plugin remote install", + description: "Start a remote install of a third party plugin package", +}); + definePermission({ name: "core.debug.dump_ws", title: "Dump WebSocket", diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts new file mode 100644 index 00000000..87ad5baa --- /dev/null +++ b/packages/lib/src/rce_ops.ts @@ -0,0 +1,26 @@ +import util from "util"; +import path from "path"; +import { exec } from "child_process"; +import { logger } from "./logging"; +const execAsync = util.promisify(exec); + +async function isDev() { + // dev: /packages/lib/dist/src/rce_ops.js + // prod: /node_modules/@clusterio/lib/dist/src/rce_ops.js + return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" +} + +async function logExec(cmd: string) { + logger.audit(`RCE | ${cmd}`); + if (!isDev()) { + await execAsync(cmd); + } +} + +export async function updatePackage(name: string) { + return logExec(`npm update --save ${name}`); +} + +export async function installPackage(name: string) { + return logExec(`npm install --save ${name}`); +} diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx index 135b9e81..6d59e1bc 100644 --- a/packages/web_ui/src/components/ControllerPage.tsx +++ b/packages/web_ui/src/components/ControllerPage.tsx @@ -17,6 +17,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import { notifyErrorHandler } from "../util/notify"; import webUiPackage from "../../package.json"; +import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; const { Title } = Typography; @@ -65,6 +66,10 @@ export default function ControllerPage() { Restart } + { + hasNpmButtonPermission() + && + } } /> diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 526a070e..9b1a247d 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -20,6 +20,7 @@ import { } from "./system_metrics"; import { formatTimestamp } from "../util/time_format"; import { useSystems } from "../model/system"; +import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; const { Title } = Typography; @@ -101,6 +102,10 @@ export default function HostViewPage() { Restart } + { + hasNpmButtonPermission() + && + } ; return diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx new file mode 100644 index 00000000..26d4b361 --- /dev/null +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -0,0 +1,166 @@ +import { useContext, useState } from "react"; +import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd"; + +import * as lib from "@clusterio/lib"; + +import { useAccount } from "../model/account"; +import { notifyErrorHandler } from "../util/notify"; +import ControlContext from "./ControlContext"; + +type NpmActionProps = { + setApplyAction(func: () => Promise): void, + form: FormInstance, + target: lib.AddressShorthand, +} + +function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + await control.sendTo(target, new lib.RemoteUpdateRequest()); + }); + + return <> + ; +} + +function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + const values = form.getFieldsValue(); + if (!values.plugin || values.plugin === "") { + throw new Error("Plugin not provided"); + } + await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin)); + }); + + return <> + + + + ; + + // eslint-disable-next-line multiline-comment-style + /* TODO Allow control access to plugins on hosts + return <> + + + + ; +} + +export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: boolean, disabled?: boolean }) { + const control = useContext(ControlContext); + const account = useAccount(); + const [open, setOpen] = useState(false); + const [formAction, setFormAction] = useState(undefined); + const [form] = Form.useForm(); + + function onValuesChange({ action } : { action?: string }) { + if (action) { + setFormAction(action); + } + } + + let applyAction: () => Promise; + const setApplyAction = (func: () => Promise) => { applyAction = func; }; + async function onOk() { + if (!applyAction) { + form.setFields([{ name: "action", errors: ["Action is required"] }]); + return; + } + await applyAction(); + + const values = form.getFieldsValue(); + if (values.restart) { + // Ternary not used because TS disliked it + if (props.target === "controller") { + await control.sendTo(props.target, new lib.ControllerRestartRequest()); + } else { + await control.sendTo(props.target, new lib.HostRestartRequest()); + } + } + + setFormAction(undefined); + setOpen(false); + } + + // TODO retrieve config so options can be disabled rather than using the error messages + return <> + + { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} + onCancel={() => { setOpen(false); }} + destroyOnClose + > +
+ + + {account.hasPermission("core.rce.remote_update") + ? Update Clusterio : undefined} + {account.hasPermission("core.rce.plugin_update") + ? Update Plugin : undefined} + {account.hasPermission("core.rce.plugin_install") + ? Install Plugin : undefined} + + + { + formAction !== undefined + && account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`) + && + + + } + {formAction === "remote_update" + ? : undefined} + {formAction === "plugin_update" + ? : undefined} + {formAction === "plugin_install" + ? : undefined} + +
+ ; +} + +export function hasNpmButtonPermission() { + const account = useAccount(); + return account.hasAnyPermission( + "core.rce.remote_update", "core.rce.plugin_update", "core.rce.plugin_install" + ); +}