From ffe82f81d8273690098ca022ede528307a550f2d Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:32:51 +0100 Subject: [PATCH] Merge pull request #1010 from bbassie/feature/plugin-browser Plugin management through web interface --- .gitignore | 1 + docs/managing-a-cluster.md | 37 +- packages/controller/controller.ts | 5 +- packages/controller/src/ControlConnection.ts | 28 +- packages/controller/src/Controller.ts | 15 +- packages/ctl/src/commands_controller.ts | 23 +- packages/ctl/src/commands_host.ts | 4 +- packages/host/host.ts | 5 +- packages/host/src/Host.ts | 30 +- packages/lib/index.ts | 1 + packages/lib/src/LockFile.ts | 173 ++++- packages/lib/src/data/messages_controller.ts | 5 + packages/lib/src/data/messages_plugin.ts | 138 +++- packages/lib/src/helpers.ts | 51 ++ packages/lib/src/link/messages.ts | 2 + packages/lib/src/load_plugin_list.ts | 309 ++++++--- packages/lib/src/npm_registry.ts | 159 +++++ packages/lib/src/permissions.ts | 5 + packages/lib/src/plugin.ts | 5 + packages/lib/src/plugin_loader.ts | 105 +++- packages/lib/src/rce_ops.ts | 103 ++- packages/lib/src/system_collectors.ts | 6 +- packages/web_ui/package.json | 4 +- packages/web_ui/src/components/NpmButton.tsx | 223 ++----- .../web_ui/src/components/PluginBrowser.tsx | 218 +++++++ .../src/components/PluginClusterStatus.tsx | 593 ++++++++++++++++++ .../web_ui/src/components/PluginInstall.tsx | 186 ++++++ .../web_ui/src/components/PluginViewPage.tsx | 152 +++-- .../web_ui/src/components/PluginsPage.tsx | 173 +++-- packages/web_ui/src/util/exclusive.ts | 25 + packages/web_ui/src/util/notify.ts | 3 +- packages/web_ui/src/util/plugin_status.ts | 233 +++++++ packages/web_ui/src/util/poll.ts | 26 + scripts/version-compat-test.js | 45 +- test/controller/ControlConnection.test.js | 4 + test/host/Host.test.js | 44 ++ test/integration/clusterio.js | 15 +- test/lib/LockFile.test.js | 173 ++++- test/lib/data/SystemInfo.js | 14 +- test/lib/helpers.js | 61 ++ test/lib/npm_registry.js | 175 ++++++ test/lib/plugin_loader.js | 356 +++++++++++ test/lib/rce_ops.js | 327 +++++++++- test/messages/plugin.test.js | 101 ++- test/web_ui/exclusive.js | 35 ++ test/web_ui/plugin_status.js | 171 +++++ test/web_ui/plugin_status_matrix.js | 196 ++++++ test/web_ui/poll.js | 48 ++ 48 files changed, 4275 insertions(+), 536 deletions(-) create mode 100644 packages/lib/src/npm_registry.ts create mode 100644 packages/web_ui/src/components/PluginBrowser.tsx create mode 100644 packages/web_ui/src/components/PluginClusterStatus.tsx create mode 100644 packages/web_ui/src/components/PluginInstall.tsx create mode 100644 packages/web_ui/src/util/exclusive.ts create mode 100644 packages/web_ui/src/util/plugin_status.ts create mode 100644 packages/web_ui/src/util/poll.ts create mode 100644 test/lib/npm_registry.js create mode 100644 test/web_ui/exclusive.js create mode 100644 test/web_ui/plugin_status.js create mode 100644 test/web_ui/plugin_status_matrix.js create mode 100644 test/web_ui/poll.js diff --git a/.gitignore b/.gitignore index 0b4b6c9a..5f99e160 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ node_modules /plugin-list.json /config-*.json /config-*.json.lock +/npm-operation.lock* pnpm-lock.yaml mod_cache.json .luarc.json diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index b3a6f582..b96c57e8 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -283,10 +283,15 @@ This command lists all plugins currently known to the remote. Newly installed pl ### Update - ctl> controller plugin update [--restart] - ctl> host plugin update [--restart] + ctl> controller plugin update [@] [--restart] + ctl> host plugin update [@] [--restart] -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. +If remote plugin updates are enabled on the remote, this will execute `npm update --save `, which stays within the version range saved in the remote's `package.json`. +To update to a specific version, including one outside that range, pass it as `@`, for example `@clusterio/plugin-example@2.1.0`. +The version must be published on npm, and the remote then executes `npm install --save @`. +The plugin must already be installed on the remote, and only remote plugin updates need to be enabled. +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 @@ -297,10 +302,30 @@ Updates the Clusterio package and all plugins installed from npm in a single `np ### Install - ctl> controller plugin install [--restart] - ctl> host plugin install [--restart] + ctl> controller plugin install [@] [--restart] + ctl> host plugin install [@] [--restart] + +If remote plugin installs are enabled on the remote, this will execute `npm install --save `. +Pass `@` to install a specific version, for example to match the version used by the rest of the cluster. +The package, and the version if given, must be published on npm. +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. + +### Search + + ctl> controller plugin search [query] [--page ] [--page-size ] + +Searches the npm registry for packages tagged with the clusterio-plugin keyword. +The search is done by the controller and does not require remote installs to be enabled. +The web UI has a plugin browser on the plugins page that uses the same search. + +### Concurrent Operations + +Only one npm operation runs at a time on a machine. +A standalone controller and host share their working directory, so the operation holds the `npm-operation.lock` file there while npm runs. +Other installs and updates on the same machine or installation are rejected with "Another package operation is already in progress on this machine" until it finishes. +A lock file left behind by a process that is no longer running is removed automatically. -If remote plugin installs are enabled on the remote, this will execute `npm install --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. ### Enable/Disable Updates diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 3c95468a..46b04cb0 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -400,7 +400,8 @@ async function initialize(): Promise { } logger.info(`Loading available plugins from ${args.pluginList}`); - let pluginList = await lib.loadPluginList(args.pluginList); + const skippedNpmPlugins = new Map(); + let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins); // If the command is plugin management we don't try to load plugins if (command === "plugin") { @@ -415,7 +416,7 @@ async function initialize(): Promise { } logger.info("Loading Plugin info"); - const pluginInfos = await lib.loadPluginInfos(pluginList); + const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins); lib.registerPluginMessages(pluginInfos); lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index b5ff212d..28c6bc6a 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -135,6 +135,8 @@ 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.PluginSearchRequest, this.handlePluginSearchRequest.bind(this)); + this.handle(lib.PluginLatestVersionsRequest, this.handlePluginLatestVersionsRequest.bind(this)); this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } @@ -197,6 +199,9 @@ export default class ControlConnection extends BaseConnection { if (!this._controller.canRestart) { throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it."); } + if (await lib.packageOperationRunning()) { + throw new lib.RequestError("Cannot restart while a plugin install or update is running on this machine."); + } const downgrade = await this._controller.checkRestartDowngrade(); if (downgrade) { const { installedVersion, runningVersion } = downgrade; @@ -1351,9 +1356,10 @@ export default class ControlConnection extends BaseConnection { if (!this._controller.config.get("controller.allow_plugin_install")) { throw new lib.RequestError("Plugin installs are disabled on this machine"); } - await lib.handlePluginInstall(request.pluginPackage); - // A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it - if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) { + const npmPackage = await lib.handlePluginInstall(request.pluginPackage); + // A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version + // changes to loaded plugins are noticed there. + if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) { this._controller.config.restartRequired = true; } } @@ -1368,14 +1374,20 @@ export default class ControlConnection extends BaseConnection { return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos); } - async handlePluginListRequest(request: lib.PluginListRequest) { - return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( - pluginInfo, - this._controller.loadedPlugins.has(pluginInfo), - this._controller.config.get(`${pluginInfo.name}.load_plugin`), + async handlePluginSearchRequest(request: lib.PluginSearchRequest) { + return await lib.searchPlugins(request.query, request.page, request.pageSize); + } + + async handlePluginLatestVersionsRequest(request: lib.PluginLatestVersionsRequest) { + return new lib.PluginLatestVersionsRequest.Response(await lib.fetchLatestVersions( + request.packages, this._controller.pluginLatestVersionCache, )); } + async handlePluginListRequest(request: lib.PluginListRequest) { + return await this._controller.listPlugins(); + } + async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) { if (this.ws_dumper) { return; // Already dumping diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index d0a12720..a72a678f 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -75,6 +75,8 @@ export default class Controller { hooks = new ControllerHooks(logger); /** Plugins which are currently loaded */ loadedPlugins: Set = new Set(); + /** Latest versions of plugins on npm, as shown on the plugins page */ + pluginLatestVersionCache: lib.LatestVersionCache = new Map(); /** WebSocket server */ wsServer: WsServer; @@ -646,7 +648,9 @@ export default class Controller { requests.push(hostConnection.send(new lib.SystemInfoRequest())); } const restartRequired = await this.checkRestartRequired(); - requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs)); + requests.push(lib.gatherSystemInfo( + "controller", this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(), + )); const newMetrics = await Promise.all(requests); for (const metric of newMetrics) { this.systems.set(metric); @@ -656,6 +660,15 @@ export default class Controller { } } + /** Plugins on the controller, including those installed or updated since it started */ + async listPlugins() { + return await lib.listPluginDetails( + this.pluginInfos, + pluginInfo => this.loadedPlugins.has(pluginInfo), + pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`), + ); + } + async checkRestartRequired() { if (this.config.restartRequired) { return true; diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts index 55417be4..781e1503 100644 --- a/packages/ctl/src/commands_controller.ts +++ b/packages/ctl/src/commands_controller.ts @@ -180,7 +180,7 @@ controllerPluginCommands.add(new lib.Command({ })); controllerPluginCommands.add(new lib.Command({ definition: ["update ", "Update a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to update", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" }); yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); }], handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) { @@ -192,9 +192,28 @@ controllerPluginCommands.add(new lib.Command({ } }, })); +controllerPluginCommands.add(new lib.Command({ + definition: ["search [query]", "Search npm for plugins", (yargs) => { + yargs.positional("query", { describe: "Text to search for", type: "string", default: "" }); + yargs.option("page", { type: "number", description: "Page of results to show", default: 1 }); + yargs.option("page-size", { type: "number", description: "Results per page", default: 20 }); + }], + handler: async function(args: { query: string, page: number, pageSize: number }, ctl: Ctl) { + const response = await ctl.sendTo( + "controller", new lib.PluginSearchRequest(args.query, args.page, args.pageSize) + ); + print(asTable(response.results.map(plugin => ({ + name: plugin.name, + version: plugin.version, + publisher: plugin.publisher ?? "", + description: plugin.description ?? "", + })))); + print(`Showing ${response.results.length} of ${response.total} results`); + }, +})); controllerPluginCommands.add(new lib.Command({ definition: ["install ", "Install a plugin on the controller", (yargs) => { - yargs.positional("plugin", { describe: "Plugin to install", type: "string" }); + yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" }); yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" }); }], handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) { diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index a3dd8b7c..3d20e050 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -278,7 +278,7 @@ hostPluginCommands.add(new lib.Command({ 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.positional("plugin", { describe: "Plugin to update, optionally as name@version", 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) { @@ -294,7 +294,7 @@ hostPluginCommands.add(new lib.Command({ 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.positional("plugin", { describe: "Plugin to install, optionally as name@version", 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) { diff --git a/packages/host/host.ts b/packages/host/host.ts index db2a9357..11ea2ba8 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -164,7 +164,8 @@ async function startHost() { } logger.info(`Loading available plugins from ${args.pluginList}`); - let pluginList = await lib.loadPluginList(args.pluginList); + const skippedNpmPlugins = new Map(); + let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins); // If the command is plugin management we don't try to load plugins if (command === "plugin") { @@ -173,7 +174,7 @@ async function startHost() { } logger.info("Loading Plugin info"); - let pluginInfos = await lib.loadPluginInfos(pluginList); + let pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins); lib.registerPluginMessages(pluginInfos); lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 4d2aff51..7700ec35 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -568,6 +568,9 @@ export default class Host extends lib.Link { if (!this.canRestart) { throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it."); } + if (await lib.packageOperationRunning()) { + throw new lib.RequestError("Cannot restart while a plugin install or update is running on this machine."); + } const downgrade = await this.checkRestartDowngrade(); if (downgrade) { const { installedVersion, runningVersion } = downgrade; @@ -952,7 +955,9 @@ export default class Host extends lib.Link { async handleSystemInfoRequest() { const restartRequired = await this.checkRestartRequired(); - return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs); + return lib.gatherSystemInfo( + this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(), + ); } async checkRestartRequired() { @@ -1264,9 +1269,10 @@ export default class Host extends lib.Link { if (!this.config.get("host.allow_plugin_install")) { throw new lib.RequestError("Plugin installs are disabled on this machine"); } - await lib.handlePluginInstall(request.pluginPackage); - // A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it - if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) { + const npmPackage = await lib.handlePluginInstall(request.pluginPackage); + // A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version + // changes to loaded plugins are noticed there. + if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) { this.config.restartRequired = true; } } @@ -1282,11 +1288,17 @@ export default class Host extends lib.Link { } async handlePluginListRequest(request: lib.PluginListRequest) { - return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( - pluginInfo, - this.loadedPlugins.has(pluginInfo), - this.config.get(`${pluginInfo.name}.load_plugin`), - )); + return await this.listPlugins(); + } + + /** Plugins on this host, including those installed or updated since it started */ + async listPlugins() { + return await lib.listPluginDetails( + this.pluginInfos, + pluginInfo => this.loadedPlugins.has(pluginInfo), + pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`), + pluginInfo => Boolean(pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint), + ); } sendHostUpdate() { diff --git a/packages/lib/index.ts b/packages/lib/index.ts index f45e5554..ca6f0540 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -35,6 +35,7 @@ export * from "./src/subscriptions.js"; export * from "./src/datastore.js"; export * from "./src/load_plugin_list.js"; export * from "./src/rce_ops.js"; +export * from "./src/npm_registry.js"; export * from "./src/LockFile.js"; export * from "./src/AsyncHook.js"; export * from "./src/SyncHook.js"; diff --git a/packages/lib/src/LockFile.ts b/packages/lib/src/LockFile.ts index afed36e1..d1ba9fed 100644 --- a/packages/lib/src/LockFile.ts +++ b/packages/lib/src/LockFile.ts @@ -1,5 +1,5 @@ -import { FileHandle, open, unlink, readFile } from "node:fs/promises"; -import { unlinkSync } from "node:fs"; +import fs, { type FileHandle } from "node:fs/promises"; +import { readFileSync, unlinkSync } from "node:fs"; import assert from "assert"; /** @@ -24,23 +24,99 @@ export class LockFileBusyError extends Error { } } +/** + * Error thrown when a process exited while clearing a stale lock, which needs the recovery file removed by hand. + */ +export class LockFileRecoveryError extends Error { + constructor( + readonly recoveryPath: string + ) { + super(`Lock file recovery was interrupted, remove ${recoveryPath} to allow the lock to be acquired`); + this.name = "LockFileRecoveryError"; + } +} + type LockState = "idle" | "acquiring" | "acquired" | "releasing"; +type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint }; /** * Cross-platform lock file utility for Node.js. */ export class LockFile { + /** + * Content written to the lock file. The start time tells this process apart from an earlier one with + * the same pid, and is the same for every copy of lib loaded in this process. + */ + private static readonly content = `${process.pid} ${performance.timeOrigin}\n`; + // A lock file without a valid pid is left alone for this long, as it may still be being written + private static readonly writeGraceMs = 10e3; + private handle: FileHandle | null = null; private state: LockState = "idle"; + /** + * @param filePath - Path of the lock file + * @param handleSignals - + * Release the lock on SIGINT, SIGTERM, SIGHUP and uncaught exceptions. Listening to these + * changes how the process exits, so without it the lock is only removed on exit. + */ constructor( - private readonly filePath: string + private readonly filePath: string, + private readonly handleSignals = true, ) {} + /** Content and identity of a lock file, read through one handle so both belong to the same file */ + private static async readLock(filePath: string): Promise { + let handle; + try { + handle = await fs.open(filePath, "r"); + } catch (err: any) { + if (err.code === "ENOENT") { + return undefined; + } + throw err; + } + try { + const stat = await handle.stat({ bigint: true }); + return { content: await handle.readFile("utf8"), ino: stat.ino, mtimeNs: stat.mtimeNs }; + } finally { + await handle.close(); + } + } + + private static isSameLock(a: LockSnapshot, b: LockSnapshot) { + return a.content === b.content && a.ino === b.ino && a.mtimeNs === b.mtimeNs; + } + + private static lockPid(lock: LockSnapshot) { + const pid = Number(lock.content.split(" ")[0]); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; + } + + /** True if the lock names a process that is still running, or may still be written to */ + private static lockIsLive(lock: LockSnapshot) { + const pid = LockFile.lockPid(lock); + if (pid === undefined) { + return Date.now() - Number(lock.mtimeNs / 1000000n) < LockFile.writeGraceMs; + } + if (pid === process.pid) { + // Held by this process, or left by an earlier process with the same pid + return lock.content === LockFile.content; + } + try { + // Signal 0 only checks that the process exists + process.kill(pid, 0); + return true; + } catch (err: any) { + return err.code !== "ESRCH"; + } + } + /** * Acquire the lock file. * Throws LockFileExistsError if already locked. * Throws LockFileBusyError if not in idle state. + * Throws LockFileRecoveryError if clearing a stale lock was interrupted. */ async acquire(): Promise { if (this.state !== "idle") { @@ -49,13 +125,10 @@ export class LockFile { this.state = "acquiring"; - if (await this.isStale()) { - await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed - } - try { - this.handle = await open(this.filePath, "wx"); - await this.handle.write(`${process.pid}\n`); + await this.removeStale(); + this.handle = await fs.open(this.filePath, "wx"); + await this.handle.write(LockFile.content); this.attachExitHandlers(); this.state = "acquired"; } catch (err: any) { @@ -81,7 +154,9 @@ export class LockFile { // Ignored: file may have already been removed by another process try { await this.handle?.close(); } catch {} - try { await unlink(this.filePath); } catch {} + if (await this.isHeldFile()) { + await fs.unlink(this.filePath).catch(() => {}); + } this.handle = null; this.state = "idle"; @@ -89,19 +164,20 @@ export class LockFile { /** * Check whether the lock is currently held by this process. - * True only if the lock is acquired and the PID in the file - * matches the current process. + * True only if the lock is acquired and the lock file was written by this process. */ async isHeld(): Promise { if (this.state !== "acquired") { return false; } + return await this.isHeldFile(); + } + private async isHeldFile() { try { - const pidInLock = Number(await readFile(this.filePath, "utf8")); - return pidInLock === process.pid; + return (await LockFile.readLock(this.filePath))?.content === LockFile.content; } catch { - // Ignored: file may not exist or be unreadable, so lock is not held + // Ignored: file may be unreadable, so lock is not held return false; } } @@ -110,14 +186,45 @@ export class LockFile { * Determine whether an existing lock is stale. */ async isStale(): Promise { + const lock = await LockFile.readLock(this.filePath); + return lock === undefined || !LockFile.lockIsLive(lock); + } + + /** Pid of the process holding the lock, if it has written it */ + async holderPid(): Promise { + const lock = await LockFile.readLock(this.filePath); + return lock && LockFile.lockPid(lock); + } + + /** + * Remove a stale lock file if it is still the same file. Clearing is done while holding a separate + * recovery lock, so a lock another process took after the stale check is never removed. + */ + private async removeStale(): Promise { + const stale = await LockFile.readLock(this.filePath); + if (!stale || LockFile.lockIsLive(stale)) { + return; + } + const recoveryPath = `${this.filePath}.recovery`; try { - const pidInLock = Number(await readFile(this.filePath, "utf8")); - if (!Number.isFinite(pidInLock)) { return true; } - // signal 0 is a special case to test if a process exists - process.kill(pidInLock, 0); - return false; - } catch { - return true; + await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" }); + } catch (err: any) { + if (err.code !== "EEXIST") { + throw err; + } + const holder = await LockFile.readLock(recoveryPath); + if (holder && !LockFile.lockIsLive(holder)) { + throw new LockFileRecoveryError(recoveryPath); + } + return; // Another process is clearing it + } + try { + const current = await LockFile.readLock(this.filePath); + if (current && LockFile.isSameLock(current, stale)) { + await fs.unlink(this.filePath); + } + } finally { + await fs.unlink(recoveryPath).catch(() => {}); } } @@ -126,10 +233,12 @@ export class LockFile { */ private attachExitHandlers(): void { assert(this.state === "acquiring"); - process.on("SIGINT", this.handleExit); - process.on("SIGTERM", this.handleExit); - process.on("SIGHUP", this.handleExit); - process.on("uncaughtException", this.handleExit); + if (this.handleSignals) { + process.on("SIGINT", this.handleExit); + process.on("SIGTERM", this.handleExit); + process.on("SIGHUP", this.handleExit); + process.on("uncaughtException", this.handleExit); + } // eslint-disable-next-line node/no-sync process.on("exit", this.handleExitSync); } @@ -137,7 +246,7 @@ export class LockFile { /** * Detach process handlers, called when transitioning to "idle" */ - private async detachExitHandlers(): Promise { + private detachExitHandlers(): void { assert(this.state === "releasing"); process.off("SIGINT", this.handleExit); process.off("SIGTERM", this.handleExit); @@ -147,17 +256,19 @@ export class LockFile { process.off("exit", this.handleExitSync); } - // Event handler for async signals / exceptions + // Event handler for async signals / exceptions private handleExit = async (): Promise => { await this.release(); }; - // Event handler for synchronous process exit + // Event handler for synchronous process exit, readLock can't be used as it is async private handleExitSync = (): void => { try { - unlinkSync(this.filePath); + if (readFileSync(this.filePath, "utf8") === LockFile.content) { + unlinkSync(this.filePath); + } } catch (err) { - // Ignored: file may have already been removed by another process + // Ignored: file may have already been removed by another process } }; } diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 4b3eb10a..41f0ec1d 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -3,6 +3,7 @@ import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js"; import { type LogEntry, levels } from "../logging.js"; import { ControllerConfig, HostConfig } from "../config/index.js"; import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js"; +import { PluginDetails } from "./messages_plugin.js"; const LogEntrySchema = Type.Object({ "level": StringKey(levels), @@ -298,6 +299,8 @@ export class SystemInfo { /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs: number, public isDeleted: boolean, + /** Plugins on the machine when last collected, kept while it is offline */ + public plugins?: PluginDetails[], ) { } static jsonSchema = Type.Object({ @@ -318,6 +321,7 @@ export class SystemInfo { "processStartedAtMs": Type.Number(), "updatedAtMs": Type.Number(), "isDeleted": Type.Boolean(), + "plugins": Type.Optional(Type.Array(PluginDetails.jsonSchema)), }); static fromJSON(json: Static) { @@ -339,6 +343,7 @@ export class SystemInfo { json.processStartedAtMs, json.updatedAtMs, json.isDeleted, + json.plugins?.map(plugin => PluginDetails.fromJSON(plugin)), ); } diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index 25eb0045..0514feee 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -14,6 +14,18 @@ export class PluginDetails { public enabled: boolean, public description?: string, public npmPackage?: string, + /** Version installed on disk, differs from version when an update is waiting for a restart */ + public installedVersion?: string, + /** Installed after the machine started, will be loaded on the next restart */ + public pendingInstall?: boolean, + /** Uninstalled after the machine started, will not be loaded on the next restart */ + public removed?: boolean, + /** Name in the root package.json dependencies, differs from npmPackage for aliases */ + public dependencyName?: string, + /** Has no entrypoint for this machine, so it is never loaded here */ + public noEntrypoint?: boolean, + /** Not loaded as another package provides a plugin with the same name */ + public nameTaken?: boolean, ) {} static jsonSchema = Type.Object({ @@ -24,12 +36,19 @@ export class PluginDetails { enabled: Type.Boolean(), description: Type.Optional(Type.String()), npmPackage: Type.Optional(Type.String()), + installedVersion: Type.Optional(Type.String()), + pendingInstall: Type.Optional(Type.Boolean()), + removed: Type.Optional(Type.Boolean()), + dependencyName: Type.Optional(Type.String()), + noEntrypoint: Type.Optional(Type.Boolean()), + nameTaken: Type.Optional(Type.Boolean()), }); static fromJSON(json: Static) { return new this( json.name, json.title, json.version, json.loaded, json.enabled, - json.description, json.npmPackage + json.description, json.npmPackage, json.installedVersion, json.pendingInstall, json.removed, + json.dependencyName, json.noEntrypoint, json.nameTaken, ); } @@ -39,6 +58,15 @@ export class PluginDetails { pluginInfo.description, pluginInfo.npmPackage, ); } + + /** Plugin package installed since startup, it is loaded on the next restart */ + static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) { + const description = typeof packageJson.description === "string" ? packageJson.description : undefined; + return new this( + packageName, packageName, packageJson.version, false, false, + description, npmPackage, packageJson.version, true, undefined, dependencyName, + ); + } } export class PluginListRequest { @@ -94,6 +122,114 @@ export class PluginInstallRequest { } } +/* Plugin package listed on the npm registry */ +export class PluginSearchResult { + constructor( + public name: string, + public version: string, + public description?: string, + public publisher?: string, + public date?: string, + public homepage?: string, + public repository?: string, + public npm?: string, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + version: Type.String(), + description: Type.Optional(Type.String()), + publisher: Type.Optional(Type.String()), + date: Type.Optional(Type.String()), + homepage: Type.Optional(Type.String()), + repository: Type.Optional(Type.String()), + npm: Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this( + json.name, json.version, json.description, json.publisher, + json.date, json.homepage, json.repository, json.npm, + ); + } +} + +export class PluginSearchRequest { + declare ["constructor"]: typeof PluginSearchRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.plugin.search"; + + constructor( + public query: string = "", + public page: number = 1, + public pageSize: number = 20, + ) {} + + static jsonSchema = Type.Object({ + query: Type.String(), + page: Type.Integer(), + pageSize: Type.Integer(), + }); + + static fromJSON(json: Static) { + return new this(json.query, json.page, json.pageSize); + } + + static Response = class Response { + constructor( + public total: number, + public results: PluginSearchResult[], + ) {} + + static jsonSchema = Type.Object({ + total: Type.Integer(), + results: Type.Array(PluginSearchResult.jsonSchema), + }); + + static fromJSON(json: Static) { + return new this(json.total, json.results.map(r => PluginSearchResult.fromJSON(r))); + } + }; +} + +/* Latest version of each package published on npm, used to offer plugin updates */ +export class PluginLatestVersionsRequest { + declare ["constructor"]: typeof PluginLatestVersionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.plugin.list"; + + constructor( + public packages: string[], + ) {} + + static jsonSchema = Type.Object({ + packages: Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.packages); + } + + static Response = class Response { + constructor( + /** Package name to its latest version, packages not found on npm are left out */ + public versions: Record, + ) {} + + static jsonSchema = Type.Object({ + versions: Type.Record(Type.String(), Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.versions); + } + }; +} + export class UpdateAllRequest { declare ["constructor"]: typeof UpdateAllRequest; static type = "request" as const; diff --git a/packages/lib/src/helpers.ts b/packages/lib/src/helpers.ts index 289f978a..baf42ea0 100644 --- a/packages/lib/src/helpers.ts +++ b/packages/lib/src/helpers.ts @@ -321,6 +321,57 @@ export function redactUrl(url: URL | string) { return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString(); } +const gitProtocols = new Set(["git:", "git+http:", "git+https:", "git+ssh:", "ssh:"]); +const repositoryShorthands = new Map([ + ["github:", "https://github.com/"], + ["gitlab:", "https://gitlab.com/"], + ["bitbucket:", "https://bitbucket.org/"], +]); + +/** + * Convert a link from untrusted metadata, like a package's homepage or repository, into a URL + * that is safe to use as the href of a link + * + * Only http and https URLs are returned, so links like `javascript:` can't run code when + * clicked. Accepts the formats npm uses for repositories: a string or an object with a `url` + * property, `git+https://`, `git://` and `git+ssh://` URLs, `git@host:path` and shorthands + * like `github:owner/repo`, which are converted to https URLs. Credentials are removed. + * + * @param link - URL string or object with a `url` property. + * @returns http(s) URL, or undefined if the link can't be converted to one. + */ +export function safeExternalUrl(link: unknown): string | undefined { + const value = typeof link === "object" && link !== null ? (link as { url?: unknown }).url : link; + if (typeof value !== "string") { + return undefined; + } + let url = value.trim(); + const scpLike = /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(url); + if (scpLike) { + url = `ssh://${scpLike[1]}/${scpLike[2]}`; + } + + let parsed: URL; + try { + parsed = new URL(url); + const shorthand = repositoryShorthands.get(parsed.protocol); + if (shorthand) { + parsed = new URL(url.slice(parsed.protocol.length).replace(/^\/+/, ""), shorthand); + } else if (gitProtocols.has(parsed.protocol)) { + // The protocol setter can't change a URL into an https URL, so parse it again + parsed = new URL(`https://${parsed.host}${parsed.pathname.replace(/\.git$/, "")}`); + } + } catch { + return undefined; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + parsed.username = ""; + parsed.password = ""; + return parsed.href; +} + /** * Copy of parsed query parameters with credentials replaced by `REDACTED` * diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 2ff37a55..117e4718 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -147,5 +147,7 @@ export const dataClasses: (RequestClass | EventClass) plugin.PluginListRequest, plugin.PluginUpdateRequest, plugin.PluginInstallRequest, + plugin.PluginSearchRequest, + plugin.PluginLatestVersionsRequest, plugin.UpdateAllRequest, ]; diff --git a/packages/lib/src/load_plugin_list.ts b/packages/lib/src/load_plugin_list.ts index d91977e2..d6838206 100644 --- a/packages/lib/src/load_plugin_list.ts +++ b/packages/lib/src/load_plugin_list.ts @@ -5,6 +5,96 @@ import { logger } from "./logging.js"; import * as libFileOps from "./file_ops.js"; import vm from "vm"; +const corePackages = new Set([ + "@clusterio/controller", + "@clusterio/ctl", + "@clusterio/host", + "@clusterio/lib", + "@clusterio/web_ui", +]); + +function isPluginPackageJson(packageJson: any): boolean { + return Boolean(packageJson?.keywords?.includes?.("clusterio-plugin")); +} + +/** + * Read the package.json in a package directory. + * @param packagePath - Directory of the package + * @param tolerant - Return undefined instead of throwing if the file can't be parsed, as npm may be writing it + * @returns The parsed package.json, or undefined if it does not exist + */ +export async function readPackageJson(packagePath: string, tolerant: boolean): Promise { + let content; + try { + content = await fs.readFile(path.join(packagePath, "package.json"), "utf8"); + } catch (err: any) { + return undefined; + } + try { + return JSON.parse(content); + } catch (err: any) { + if (tolerant) { + return undefined; + } + throw err; + } +} + +/** Dependencies npm installs, an optionalDependencies entry wins over a dependencies entry of the same name */ +export function installedDependencies(packageJson: any): Record { + return { ...packageJson?.dependencies, ...packageJson?.optionalDependencies }; +} + +/** Whether a dependency spec installs from the registry, as opposed to a path, git, url, tarball or alias */ +export function isRegistrySpec(spec: unknown) { + return typeof spec === "string" && !/[:/\\]/.test(spec) && !spec.startsWith(".") + && !/\.(?:tgz|tar\.gz|tar)$/i.test(spec); +} + +/** Real path of a directory, used to compare packages as npm and pnpm install them behind symlinks */ +export async function realDir(dir: string) { + try { + return await fs.realpath(dir); + } catch { + // Removed since, nothing else can be at this path + return path.resolve(dir); + } +} + +/** Directory of a package as require resolves it from fromPath, or undefined if not found */ +export function resolvePackagePath(packageName: string, fromPath: string, tolerant: boolean): string | undefined { + const packageRequire = createRequire(path.join(fromPath, "package.json")); + try { + return path.dirname(packageRequire.resolve(`${packageName}/package.json`)); + } catch (err: any) { + // A package.json npm is still writing fails with ERR_INVALID_PACKAGE_CONFIG + if (tolerant || err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") { + return undefined; + } + throw err; + } +} + +async function pluginPathExists(pluginPath: string) { + if (!path.isAbsolute(pluginPath)) { + return resolvePackagePath(pluginPath, process.cwd(), true) !== undefined; + } + try { + await fs.access(path.join(pluginPath, "package.json")); + return true; + } catch { + return false; + } +} + +const require = createRequire(import.meta.url); +function getPluginName(requireSpec: string) { + const context = vm.createContext({ require: require, pluginInfo: null }); + const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`; + vm.runInContext(code, context); + return context.pluginInfo.name; +} + /** * Searches for local plugins in the plugins/ and external_plugins/ directories * and adds them to the plugin list if they contain a package.json file. @@ -49,7 +139,7 @@ async function findLocalPlugins(pluginList: Map): Promise): Promise 0; } -const require = createRequire(import.meta.url); -function getPluginName(requireSpec: string) { - const context = vm.createContext({ require: require, pluginInfo: null }); - const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`; - vm.runInContext(code, context); - return context.pluginInfo.name; +type NpmPluginPackage = { + packageName: string, + packageVersion: unknown, + packagePath: string, + packageJson: any, +}; + +/** + * Walk the dependencies of the root package.json and of every plugin package found, without + * traversing packages that aren't plugins. Does not execute any plugin code. + * + * @param dependencies - Dependencies of the root package.json + * @param tolerant - Skip packages with a package.json that can't be parsed instead of throwing, + * for use while npm may be rewriting them + * @returns Plugin packages found, and root dependencies that are not plugins + */ +export async function discoverNpmPluginPackages(dependencies: Record, tolerant = false) { + const plugins: NpmPluginPackage[] = []; + const nonPluginRoots: { packageName: string, packageVersion: unknown }[] = []; + const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({ + packageName, + packageVersion, + parentPath: process.cwd(), + isRootDependency: true, + })); + const visitedPackages = new Set(); + // Directories some dependent installs from a path, git, url, tarball or alias + const nonRegistryDirs = new Set(); + + while (pendingPackages.length) { + const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!; + const packagePath = resolvePackagePath(packageName, parentPath, tolerant); + if (!packagePath) { + continue; + } + if (!isRegistrySpec(packageVersion)) { + nonRegistryDirs.add(await realDir(packagePath)); + } + if (visitedPackages.has(packageName)) { + continue; + } + + visitedPackages.add(packageName); + + const packageJson = await readPackageJson(packagePath, tolerant); + if (packageJson && isPluginPackageJson(packageJson)) { + plugins.push({ packageName, packageVersion, packagePath, packageJson }); + for (const [dependencyName, dependencyVersion] of Object.entries(installedDependencies(packageJson))) { + pendingPackages.push({ + packageName: dependencyName, + packageVersion: dependencyVersion, + parentPath: packagePath, + isRootDependency: false, + }); + } + } else if (isRootDependency) { + nonPluginRoots.push({ packageName, packageVersion }); + } + } + return { plugins, nonPluginRoots, nonRegistryDirs }; } -function resolvePackagePath(packageName: string, fromPath: string): string | undefined { - const packageRequire = createRequire(path.join(fromPath, "package.json")); - try { - return path.dirname(packageRequire.resolve(`${packageName}/package.json`)); - } catch (err: any) { - if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") { - return undefined; - } - throw err; +/** Real paths of plugin packages some dependent installs from a path, git, url, tarball or alias */ +export async function findNonRegistryDirs() { + const dependencies = installedDependencies(await readPackageJson(process.cwd(), true)); + return (await discoverNpmPluginPackages(dependencies, true)).nonRegistryDirs; +} + +/** Whether npm install name would replace a plugin package not installed from the registry */ +export async function isNonRegistryPackage(name: string) { + const dependencies = installedDependencies(await readPackageJson(process.cwd(), true)); + if (Object.hasOwn(dependencies, name) && !isRegistrySpec(dependencies[name])) { + return true; } + const packagePath = resolvePackagePath(name, process.cwd(), true); + if (!packagePath) { + return false; + } + return (await findNonRegistryDirs()).has(await realDir(packagePath)); } /** @@ -92,79 +243,46 @@ function resolvePackagePath(packageName: string, fromPath: string): string | und * Adds discovered plugins to the plugin list. * * @param pluginList - Map of plugin names to their file paths + * @param skippedNpmPlugins - Filled with the package directory by plugin name for packages + * skipped because their plugin name was already in the list * @returns Promise that resolves to true if any plugins were added to the list, false otherwise */ -async function findNpmPlugins(pluginList: Map): Promise { - let dependencies; - try { - const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" })); - dependencies = rootPackageJson.dependencies; - } catch (err: any) { - if (err.code === "ENOENT") { - // Skip if package.json doesn't exist - return false; - } - throw err; - } - - if (!dependencies) { +async function findNpmPlugins( + pluginList: Map, + skippedNpmPlugins?: Map, +): Promise { + const rootPackageJson = await readPackageJson(process.cwd(), false); + if (!rootPackageJson) { return false; } + const dependencies = installedDependencies(rootPackageJson); let hasChanged = 0; - const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({ - packageName, - packageVersion, - parentPath: process.cwd(), - isRootDependency: true, - })); - const corePackages = new Set([ - "@clusterio/controller", - "@clusterio/ctl", - "@clusterio/host", - "@clusterio/lib", - "@clusterio/web_ui", - ]); - const visitedPackages = new Set(); - - while (pendingPackages.length) { - const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!; - if (visitedPackages.has(packageName)) { + const { plugins, nonPluginRoots } = await discoverNpmPluginPackages(dependencies); + for (const { packageName, packageVersion, packagePath } of plugins) { + const pluginName = getPluginName(packagePath); + const listedPath = pluginList.get(pluginName); + if (listedPath === packageName) { continue; } - - const npmPackagePath = resolvePackagePath(packageName, parentPath); - if (!npmPackagePath) { - continue; - } - - visitedPackages.add(packageName); - - if (await checkPackageJson(npmPackagePath)) { - const pluginName = getPluginName(npmPackagePath); - if (pluginList.has(pluginName)) { - logger.warn( - `${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` + - `but with a different path - using ${pluginList.get(pluginName)}` - ); - } else { - pluginList.set(pluginName, packageName); - logger.info(`Added ${pluginName} from node modules`); - hasChanged += 1; - } - - const packageJson = JSON.parse( - await fs.readFile(path.join(npmPackagePath, "package.json"), { encoding: "utf8" }) + if (listedPath !== undefined && !await pluginPathExists(listedPath)) { + logger.warn(`${pluginName} at ${listedPath} no longer exists, using ${packageName}`); + pluginList.set(pluginName, packageName); + hasChanged += 1; + } else if (pluginList.has(pluginName)) { + logger.warn( + `${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` + + `but with a different path - using ${pluginList.get(pluginName)}` ); - for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) { - pendingPackages.push({ - packageName: dependencyName, - packageVersion: dependencyVersion, - parentPath: npmPackagePath, - isRootDependency: false, - }); - } - } else if (isRootDependency && !corePackages.has(packageName)) { + skippedNpmPlugins?.set(pluginName, await realDir(packagePath)); + } else { + pluginList.set(pluginName, packageName); + logger.info(`Added ${pluginName} from node modules`); + hasChanged += 1; + } + } + for (const { packageName, packageVersion } of nonPluginRoots) { + if (!corePackages.has(packageName)) { logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`); } } @@ -176,9 +294,15 @@ async function findNpmPlugins(pluginList: Map): Promise * * @param pluginListPath - Path to the plugin list JSON file * @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories + * @param skippedNpmPlugins - Filled with the package directory by plugin name for npm packages + * skipped because another plugin has the same name, for loadPluginInfos * @returns Map of plugin name to plugin path */ -export async function loadPluginList(pluginListPath: string, findLocal = true): Promise> { +export async function loadPluginList( + pluginListPath: string, + findLocal = true, + skippedNpmPlugins?: Map, +): Promise> { let pluginList = new Map(); // Try to load existing plugin list @@ -201,7 +325,7 @@ export async function loadPluginList(pluginListPath: string, findLocal = true): } // Discover NPM plugins - hasChanges = await findNpmPlugins(pluginList) || hasChanges; + hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges; if (hasChanges) { await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t")); @@ -209,24 +333,3 @@ export async function loadPluginList(pluginListPath: string, findLocal = true): return pluginList; } - -/** - * Helper function to check if a package.json file exists and contains the clusterio-plugin keyword - * @param pluginPath - Path to the plugin directory - * @returns Promise that resolves to true if package.json exists and has clusterio-plugin keyword - */ -async function checkPackageJson(pluginPath: string): Promise { - const packageJsonPath = path.join(pluginPath, "package.json"); - - // Read and parse package.json - let content; - try { - content = await fs.readFile(packageJsonPath, { encoding: "utf8" }); - } catch (err: any) { - return false; - } - const packageJson = JSON.parse(content); - - // Check if package has the clusterio-plugin keyword - return packageJson.keywords?.includes("clusterio-plugin"); -} diff --git a/packages/lib/src/npm_registry.ts b/packages/lib/src/npm_registry.ts new file mode 100644 index 00000000..a36090fd --- /dev/null +++ b/packages/lib/src/npm_registry.ts @@ -0,0 +1,159 @@ +import { logger } from "./logging.js"; +import { RequestError } from "./errors.js"; +import { PluginSearchRequest, PluginSearchResult } from "./data/messages_plugin.js"; + +const registryTimeoutMs = 10e3; + +// One name segment, optionally after an @scope/ segment. Not starting with . or - keeps npm from +// reading the spec as a path, a git repository or a command line option. +// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name +const packageNamePattern = /^(?:@[a-zA-Z0-9][a-zA-Z0-9\-._~]*\/)?[a-zA-Z0-9][a-zA-Z0-9\-._~]*$/; + +/** + * Split a package spec into its name and optional version, as in name@version. The name itself + * may start with @ for scoped packages. + */ +export function parsePackageSpec(spec: string) { + const versionSeparator = spec.lastIndexOf("@"); + const [name, version] = versionSeparator > 0 + ? [spec.slice(0, versionSeparator), spec.slice(versionSeparator + 1)] + : [spec, undefined]; + if (name.length > 214 || !packageNamePattern.test(name)) { + throw new RequestError(`Invalid plugin name: ${spec}`); + } + if (version !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9\-.+]*$/.test(version)) { + throw new RequestError(`Invalid plugin version: ${version}`); + } + return { name, version }; +} + +/** Throws unless the package, and version if given, is published on npm, returns the spec to install */ +export async function checkRegistry(name: string, version?: string) { + const packageName = encodeURI(name); + const spec = version !== undefined ? `${packageName}@${version}` : packageName; + const registryPath = version !== undefined ? `${packageName}/${encodeURIComponent(version)}` : packageName; + const npmRequest = await fetch(`https://registry.npmjs.com/${registryPath}`, { + method: "HEAD", + signal: AbortSignal.timeout(registryTimeoutMs), + }); + if (!npmRequest.ok) { + throw new RequestError(`Unknown plugin: ${spec}`); + } + return spec; +} + +const latestVersionCacheMs = 5 * 60e3; +const latestVersionCacheMaxSize = 1000; +const latestVersionConcurrency = 8; + +/** Latest version of each package by name, as found on npm, with when it was looked up */ +export type LatestVersionCache = Map; + +/** + * Look up the latest published version of each package on npm, packages that can't be + * found are left out. Lookups are kept in the given cache for a few minutes. + */ +export async function fetchLatestVersions(packages: string[], cache: LatestVersionCache) { + if (packages.length > 500) { + throw new RequestError("Too many packages"); + } + const names = [...new Set(packages.map(packageName => { + const { name, version: pinned } = parsePackageSpec(packageName); + if (pinned !== undefined) { + throw new RequestError(`Invalid plugin name: ${packageName}`); + } + return name; + }))]; + + const now = Date.now(); + for (const [name, cached] of cache) { + if (now - cached.fetchedAt > latestVersionCacheMs) { + cache.delete(name); + } + } + + const versions: Record = {}; + const queue = [...names]; + async function lookUp(name: string) { + let cached = cache.get(name); + if (!cached) { + let response; + try { + response = await fetch(`https://registry.npmjs.com/${name}/latest`, { + signal: AbortSignal.timeout(registryTimeoutMs), + }); + } catch (err: any) { + logger.warn(`Failed to look up latest version of ${name}: ${err.message}`); + return; + } + if (!response.ok && response.status !== 404) { + // Rate limits and outages are retried on the next lookup instead of cached + logger.warn(`Failed to look up latest version of ${name}: ${response.status}`); + return; + } + const version = response.ok ? (await response.json() as { version?: string }).version : undefined; + cached = { version, fetchedAt: now }; + for (const oldest of cache.keys()) { + if (cache.size < latestVersionCacheMaxSize) { + break; + } + cache.delete(oldest); + } + cache.set(name, cached); + } + if (cached.version) { + versions[name] = cached.version; + } + } + await Promise.all(Array.from({ length: latestVersionConcurrency }, async () => { + for (let name = queue.shift(); name !== undefined; name = queue.shift()) { + await lookUp(name); + } + })); + return versions; +} + +const npmSearchMaxSize = 250; // Limit imposed by the registry + +export async function searchPlugins(query: string, page: number, pageSize: number) { + if (query.length > 214) { + throw new RequestError("Search query too long"); + } + if (page < 1 || pageSize < 1 || pageSize > npmSearchMaxSize) { + throw new RequestError("Invalid page or page size"); + } + + const url = new URL("https://registry.npmjs.com/-/v1/search"); + url.searchParams.set("text", `keywords:clusterio-plugin ${query}`.trim()); + url.searchParams.set("size", String(pageSize)); + url.searchParams.set("from", String((page - 1) * pageSize)); + + const response = await fetch(url, { signal: AbortSignal.timeout(registryTimeoutMs) }); + if (!response.ok) { + throw new RequestError(`npm registry search failed: ${response.status} ${response.statusText}`); + } + + const json = await response.json() as { + total: number, + objects: { + package: { + name: string, + version: string, + description?: string | null, + date?: string | null, + publisher?: { username?: string | null } | null, + links?: { npm?: string | null, homepage?: string | null, repository?: string | null } | null, + }, + }[], + }; + + return new PluginSearchRequest.Response( + json.total, + json.objects.map(({ package: pkg }) => new PluginSearchResult( + // The registry sends null for some missing fields + pkg.name, pkg.version, pkg.description ?? undefined, pkg.publisher?.username ?? undefined, + pkg.date ?? undefined, pkg.links?.homepage ?? undefined, pkg.links?.repository ?? undefined, + pkg.links?.npm ?? undefined, + )), + ); +} diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 64e1ec4d..26f2c81e 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -583,6 +583,11 @@ export const corePermissions = [ title: "Install plugin", description: "Remotely install a plugin if the target allows remote installs of plugins.", }, + { + name: "core.plugin.search", + title: "Search plugins", + description: "Search the npm registry for plugins via the controller.", + }, { name: "core.debug.dump_ws", diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 18d581aa..7320b608 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -82,6 +82,11 @@ export type PluginNodeEnvInfo = PluginDeclaration & { * package. */ npmPackage?: string; + /** + * Directory of an npm package providing a plugin with the same name + * that was skipped on startup as this plugin was already in the list. + */ + skippedNpmPackage?: string; }; export type PluginWebpackEnvInfo = PluginDeclaration & { diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 8194ce7c..04c4cf70 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -13,6 +13,10 @@ import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js"; import { Config } from "./config/classes.js"; import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js"; import Role from "./data/Role.js"; +import { PluginDetails } from "./data/messages_plugin.js"; +import { + discoverNpmPluginPackages, findNonRegistryDirs, installedDependencies, readPackageJson, realDir, resolvePackagePath, +} from "./load_plugin_list.js"; /** @@ -24,10 +28,13 @@ import Role from "./data/Role.js"; * * @param pluginList - * Mapping of plugin name to require path for the plugins to load. + * @param skippedNpmPlugins - + * Directory of the npm package skipped by plugin name, from loadPluginList. * @returns Array of plugin info modules. */ -export async function loadPluginInfos(pluginList: Map) { +export async function loadPluginInfos(pluginList: Map, skippedNpmPlugins?: Map) { let plugins: libPlugin.PluginNodeEnvInfo[] = []; + const nonRegistryDirs = await findNonRegistryDirs(); for (let [pluginName, pluginPath] of pluginList) { let pluginInfo: libPlugin.PluginNodeEnvInfo; let pluginPackage: { name?: string, version: string, main?: string, private?: boolean }; @@ -87,12 +94,106 @@ export async function loadPluginInfos(pluginList: Map) { pluginInfo.packagePath = packagePath; pluginInfo.requirePath = pluginPath; pluginInfo.version = pluginPackage.version; - pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name ? pluginPath : undefined; + pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name + && !nonRegistryDirs.has(await fs.realpath(path.dirname(packagePath))) ? pluginPath : undefined; + pluginInfo.skippedNpmPackage = skippedNpmPlugins?.get(pluginName); plugins.push(pluginInfo); } return plugins; } +/** + * Lists the plugins on this machine along with the versions currently installed on disk, + * including npm plugins installed since startup that will only be loaded after a restart. + * + * @param pluginInfos - Plugins discovered when this machine started + * @param isLoaded - Whether the plugin is loaded + * @param isEnabled - Whether the plugin is enabled + * @param hasEntrypoint - Whether the plugin has an entrypoint this machine loads + * @returns Details of every plugin on this machine + */ +export async function listPluginDetails( + pluginInfos: libPlugin.PluginNodeEnvInfo[], + isLoaded: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean, + isEnabled: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean, + hasEntrypoint: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean = () => true, +): Promise { + const plugins: PluginDetails[] = []; + const dependencies = installedDependencies(await readPackageJson(process.cwd(), true)); + // By directory, as a plugin loaded from a path may also be an npm dependency + const knownDirs = new Set(); + // Packages skipped on startup by directory, for plugins still installed + const takenDirs = new Set(); + const { plugins: npmPlugins, nonRegistryDirs } = await discoverNpmPluginPackages(dependencies, true); + for (const pluginInfo of pluginInfos) { + const dir = await realDir(path.dirname(pluginInfo.packagePath)); + knownDirs.add(dir); + // Without an entrypoint for this machine the plugin has no load_plugin field here + const loadable = hasEntrypoint(pluginInfo); + const plugin = PluginDetails.fromNodeEnvInfo( + pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo), + ); + if (!loadable) { + plugin.noEntrypoint = true; + } + if (Object.hasOwn(dependencies, pluginInfo.requirePath)) { + plugin.dependencyName = pluginInfo.requirePath; + } + if (nonRegistryDirs.has(dir)) { + plugin.npmPackage = undefined; + } + let packageJson; + try { + packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false); + if (packageJson === undefined) { + // Uninstalled since startup, it won't be found on the next restart + plugin.removed = true; + } + } catch { + // Ignored: npm is still writing it + } + if (packageJson) { + plugin.installedVersion = packageJson.version; + if (packageJson.name !== plugin.npmPackage) { + // Replaced by an alias since startup + plugin.npmPackage = undefined; + } + } + if (!plugin.removed && pluginInfo.skippedNpmPackage !== undefined) { + takenDirs.add(pluginInfo.skippedNpmPackage); + } + plugins.push(plugin); + } + + for (const { packageName, packagePath, packageJson } of npmPlugins) { + const dir = await realDir(packagePath); + if (knownDirs.has(dir)) { + continue; + } + // Startup loads it by package name from the root, which doesn't reach nested packages + const rootPath = resolvePackagePath(packageName, process.cwd(), true); + if (!rootPath || await realDir(rootPath) !== dir) { + continue; + } + if (typeof packageJson.version !== "string") { + continue; + } + // Same rule as loadPluginInfos, aliases, private and non-registry packages can't be installed by this name + const npmPackage = !packageJson.private && packageJson.name === packageName && !nonRegistryDirs.has(dir) + ? packageName : undefined; + const dependencyName = Object.hasOwn(dependencies, packageName) ? packageName : undefined; + const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName); + if (takenDirs.has(dir)) { + // Skipped on startup as its plugin name is taken, will be again + plugin.pendingInstall = undefined; + plugin.nameTaken = true; + } + plugins.push(plugin); + } + + return plugins; +} + /** * Load the migration entrypoints of plugins * diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index 1946a5eb..e030d57a 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -4,6 +4,9 @@ import { exec } from "child_process"; import { logger } from "./logging.js"; import { RequestError } from "./errors.js"; import type { PluginNodeEnvInfo } from "./plugin.js"; +import { isNonRegistryPackage } from "./load_plugin_list.js"; +import { LockFile, LockFileExistsError, LockFileRecoveryError } from "./LockFile.js"; +import { checkRegistry, parsePackageSpec } from "./npm_registry.js"; const execAsync = util.promisify(exec); function isDev() { @@ -12,10 +15,55 @@ function isDev() { return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" } -async function logExec(cmd: string) { - logger.audit(`RCE | ${cmd}`); - if (!isDev()) { - await execAsync(cmd); +const operationInProgressMessage = "Another package operation is already in progress on this machine"; + +let operationInProgress = false; + +/** + * Run an npm command, at most one at a time per installation. npm runs in the working directory, + * which a standalone controller and host share, so a lock file there guards against operations + * from other processes and other copies of lib. The flag makes concurrent calls through this copy + * reject without racing a release of the lock. + * + * @param cmd - Command to run + * @param check - Called while holding the lock before running the command, may throw to cancel it + */ +async function logExec(cmd: string, check?: () => Promise) { + // Checked and set before the first await so concurrent calls in this process can't interleave + if (operationInProgress) { + throw new RequestError(operationInProgressMessage); + } + operationInProgress = true; + const lockPath = path.resolve("npm-operation.lock"); + // Signal listeners would change how the process exits. If only this process is killed npm keeps + // running unlocked, Ctrl+C, systemd and containers stop it too. + const lock = new LockFile(lockPath, false); + try { + try { + await lock.acquire(); + } catch (err) { + if (err instanceof LockFileRecoveryError) { + throw new RequestError(err.message); + } + if (!(err instanceof LockFileExistsError)) { + throw err; + } + const holder = await lock.holderPid(); + throw new RequestError( + `${operationInProgressMessage}${holder ? ` (${lockPath} is held by process ${holder})` : ""}` + ); + } + try { + await check?.(); + logger.audit(`RCE | ${cmd}`); + if (!isDev()) { + await execAsync(cmd); + } + } finally { + await lock.release(); + } + } finally { + operationInProgress = false; } } @@ -23,16 +71,13 @@ export async function updatePackage(...names: string[]) { return logExec(`npm update --save ${names.join(" ")}`); } -export async function installPackage(name: string) { - return logExec(`npm install --save ${name}`); +export async function installPackage(name: string, check?: () => Promise) { + return logExec(`npm install --save ${name}`, check); } -export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) { - if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) { - throw new RequestError(`Plugin ${pluginName} is not installed on this machine`); - } - - return await updatePackage(pluginName); +/** Whether an npm operation is running in this installation, from this or another process */ +export async function packageOperationRunning() { + return operationInProgress || !await new LockFile(path.resolve("npm-operation.lock")).isStale(); } export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) { @@ -42,20 +87,30 @@ export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNo 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 - // https://www.npmjs.com/package/validate-npm-package-name - throw new RequestError(`Invalid plugin name: ${pluginName}`); +/** Throws if the dependency by this name is a path, git, url or alias that installing by name would replace */ +async function checkNotLocalDependency(name: string) { + if (await isNonRegistryPackage(name)) { + throw new RequestError(`${name} is not installed from npm on this machine`); } +} - const packageName = encodeURI(pluginName); - const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, { - method: "HEAD", - }); - if (!npmRequest.ok) { - throw new RequestError(`Unknown plugin: ${packageName}`); +export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) { + // Optionally pinned to a version as name@version to update past the range in package.json + const { name, version } = parsePackageSpec(pluginName); + if (!pluginInfos.some(plugin => plugin.npmPackage === name)) { + throw new RequestError(`Plugin ${name} is not installed on this machine`); } - return await installPackage(packageName); + if (version === undefined) { + return await updatePackage(name); + } + // Checked under the lock as another operation may change where it's installed from + return await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name)); +} + +/** Install a plugin, optionally pinned to a version as name@version, returns the package name */ +export async function handlePluginInstall(pluginName: string) { + const { name, version } = parsePackageSpec(pluginName); + await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name)); + return name; } diff --git a/packages/lib/src/system_collectors.ts b/packages/lib/src/system_collectors.ts index 6132dfe2..5f56e5c9 100644 --- a/packages/lib/src/system_collectors.ts +++ b/packages/lib/src/system_collectors.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "os"; import util from "util"; import { Gauge } from "./prometheus.js"; -import { SystemInfo } from "./data/index.js"; +import { PluginDetails, SystemInfo } from "./data/index.js"; function filterCpuModel() { const model = os.cpus()[0].model; @@ -125,7 +125,8 @@ export async function gatherSystemInfo( id: SystemInfo["id"], canRestart: boolean, restartRequired: boolean, - startedAtMs: number + startedAtMs: number, + plugins?: PluginDetails[], ) { const cpus = os.cpus(); const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys); @@ -160,5 +161,6 @@ export async function gatherSystemInfo( startedAtMs, now, false, + plugins, ); } diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 3d5c0927..61a4ef33 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -54,6 +54,7 @@ "react-dom": "catalog:", "react-markdown": "catalog:", "react-router-dom": "catalog:", + "semver": "catalog:", "stream-browserify": "^3.0.0", "style-loader": "^4.0.0", "swc-loader": "^0.2.7", @@ -67,6 +68,7 @@ "devDependencies": { "@types/node": "catalog:", "@types/react": "catalog:", - "@types/react-dom": "^18.3.7" + "@types/react-dom": "^18.3.7", + "@types/semver": "catalog:" } } diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index e543cb43..446670d5 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -1,5 +1,5 @@ import { useContext, useEffect, useState } from "react"; -import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd"; +import { Button, Checkbox, Modal, Tooltip } from "antd"; import * as lib from "@clusterio/lib"; @@ -7,92 +7,7 @@ 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 () => { - if (target === "controller") { - return await control.send(new lib.ControllerUpdateRequest()); - } - return await control.sendTo(target, new lib.HostUpdateRequest()); - }); - - return <> - ; -} - -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([]); - - 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)); - }); - - useEffect(() => { - control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => { - setPlugins(newPlugins); - }).catch(notifyErrorHandler("Error fetching role list")); - }, [target]); - - return <> - - - - ; -} - +/** Updates Clusterio on a machine, plugins are installed and updated from the plugins page */ export function NpmButton(props: { target: "controller" | { hostId: number }, canRestart?: boolean, @@ -101,138 +16,72 @@ export function NpmButton(props: { const control = useContext(ControlContext); const account = useAccount(); const [open, setOpen] = useState(false); - const [formAction, setFormAction] = useState(undefined); - const [form] = Form.useForm(); - - const [allows, setAllows] = useState({ - remoteUpdates: true, pluginUpdates: true, pluginInstall: true, - }); + const [restart, setRestart] = useState(false); + const [allowed, setAllowed] = useState(true); + const isController = props.target === "controller"; const hostId = props.target === "controller" ? undefined : props.target.hostId; useEffect(() => { + // Without config permission assume it's allowed so the request can still be sent + if (!account.hasPermission(isController ? "core.controller.get_config" : "core.host.get_config")) { + setAllowed(true); + return; + } if (props.target === "controller") { - if (!account.hasPermission("core.controller.get_config")) { - setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true }); - return; // If a user doesn't have config perms, then assume true to allow the sending the request - } - control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => { + control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => { const config = lib.ControllerConfig.fromJSON(serializedConfig, "control"); - setAllows({ - remoteUpdates: config.get("controller.allow_remote_updates"), - pluginUpdates: config.get("controller.allow_plugin_updates"), - pluginInstall: config.get("controller.allow_plugin_install"), - }); + setAllowed(config.get("controller.allow_remote_updates")); }).catch(notifyErrorHandler("Failed to fetch controller config for remote updates")); } else { - if (!account.hasPermission("core.host.get_config")) { - setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true }); - return; // If a user doesn't have config perms, then assume true to allow the sending the request - } - control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => { + control.sendTo(props.target, new lib.HostConfigGetRequest()).then(serializedConfig => { const config = lib.HostConfig.fromJSON(serializedConfig, "control"); - setAllows({ - remoteUpdates: config.get("host.allow_remote_updates"), - pluginUpdates: config.get("host.allow_plugin_updates"), - pluginInstall: config.get("host.allow_plugin_install"), - }); + setAllowed(config.get("host.allow_remote_updates")); }).catch(notifyErrorHandler("Failed to fetch host config for remote updates")); } }, [hostId]); - const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const; - - function onValuesChange({ action } : { action?: string }) { - if (action) { - setFormAction(action); - } - } - - let applyAction: () => Promise; - const setApplyAction = (func: () => Promise) => { applyAction = func; }; + const canRestart = account.hasPermission(isController ? "core.controller.restart" : "core.host.restart"); async function onOk() { - if (!applyAction) { - form.setFields([{ name: "action", errors: ["Action is required"] }]); - return; + if (props.target === "controller") { + await control.send(new lib.ControllerUpdateRequest()); + } else { + await control.sendTo(props.target, new lib.HostUpdateRequest()); } - await applyAction(); - - const values = form.getFieldsValue(); - if (values.restart) { - // Ternary not used because TS disliked it + if (restart) { if (props.target === "controller") { - await control.sendTo(props.target, new lib.ControllerRestartRequest()); + await control.send(new lib.ControllerRestartRequest()); } else { await control.sendTo(props.target, new lib.HostRestartRequest()); } } - - setFormAction(undefined); setOpen(false); } return <> - + + + { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} + onOk={() => { onOk().catch(notifyErrorHandler("Error updating Clusterio")); }} onCancel={() => { setOpen(false); }} destroyOnHidden > -
- - - {account.hasPermission(coreUpdatePermission) - ? - Update Clusterio - : undefined} - {account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update") - ? - Update All - : undefined} - {account.hasPermission("core.plugin.update") - ? - Update Plugin - : undefined} - {account.hasPermission("core.plugin.install") - ? - Install Plugin - : undefined} - - - { - formAction !== undefined - && account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`) - && - - - } - {formAction === "remote_update" - ? : undefined} - {formAction === "update_all" - ? : undefined} - {formAction === "plugin_update" - ? : undefined} - {formAction === "plugin_install" - ? : undefined} - +

Updates Clusterio within the version range in the machine's package.json.

+ {canRestart && { setRestart(event.target.checked); }} + >Restart on completion}
; } export function hasNpmButtonPermission(controller: boolean) { const account = useAccount(); - return account.hasAnyPermission( - controller ? "core.controller.update" : "core.host.update", - "core.plugin.update", "core.plugin.install" - ); + return account.hasPermission(controller ? "core.controller.update" : "core.host.update"); } diff --git a/packages/web_ui/src/components/PluginBrowser.tsx b/packages/web_ui/src/components/PluginBrowser.tsx new file mode 100644 index 00000000..4851dd2a --- /dev/null +++ b/packages/web_ui/src/components/PluginBrowser.tsx @@ -0,0 +1,218 @@ +import { useContext, useEffect, useState } from "react"; +import { Button, Descriptions, Input, Modal, Select, Space, Table, Typography } from "antd"; +import SearchOutlined from "@ant-design/icons/SearchOutlined"; + +import * as lib from "@clusterio/lib"; + +import { useAccount } from "../model/account"; +import { useHosts } from "../model/host"; +import { notifyErrorHandler } from "../util/notify"; +import ControlContext from "./ControlContext"; +import { InstallTarget, PluginInstaller, RemoteInstallDisabledNotice, addressKey } from "./PluginInstall"; +import { PluginInstalledTag, PluginUpdateButton, PluginVersions } from "./PluginClusterStatus"; +import type { PluginStatus } from "../util/plugin_status"; + +const { Text, Link } = Typography; + +/** Statuses are undefined until the plugin lists of the machines have loaded, or without core.plugin.list */ +type BrowserProps = { statuses?: Map, installer: PluginInstaller }; + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +// Installs on the chosen machines, defaulting to every connected machine that allows installs. +function InstallControls(props: { packageSpec: string, installer: PluginInstaller }) { + const account = useAccount(); + const [hosts] = useHosts(); + // Tracks what was deselected so machines connecting later, or allowing installs, start selected + const [deselected, setDeselected] = useState(new Set()); + const { access, installing, restarting } = props.installer; + + const machines: (InstallTarget & { key: string, allowed: boolean })[] = [ + { name: "Controller", address: "controller" as lib.AddressShorthand }, + ...[...hosts.values()] + .filter(host => host.connected) + .sort((a, b) => strcmp(a.name, b.name)) + .map(host => ({ name: host.name, address: { hostId: host.id } as lib.AddressShorthand })), + ].map(target => ({ ...target, key: addressKey(target.address), allowed: access.canInstall(target.address) })); + const selected = machines.filter(machine => machine.allowed && !deselected.has(machine.key)); + const blocked = machines.filter(machine => !machine.allowed); + + return + + Install on +