Compare commits

...
Author SHA1 Message Date
Danielv123 6e36c02ade Load plugins installed from npm by package name
Plugins installed from npm are listed by package name, but the plugin
loader joined that name onto the main and entrypoint paths and imported
the result as a file, which resolves relative to the working directory.
Every npm installed plugin then failed to load. Resolve these paths from
the directory of the plugin's package.json instead.
2026-10-11 13:22:14 +02:00
Danielv123 b411c5bef8 Fix running Clusterio from published packages
Since the move to ES modules and pnpm 12, packages installed from npm
fail to start:

- The package imports of @clusterio/lib point Node at TypeScript
  sources, which Node refuses to strip types from inside node_modules.
- The controller, host and ctl import their package.json from next to
  the compiled entrypoint, a copy pnpm leaves out of the package.
- ctl, host and web_ui have no .npmignore, so pnpm packs them using the
  repository .gitignore and leaves out nearly all of dist/.

Resolve the package imports to the compiled files at runtime while
keeping the sources for type checking, import package.json by package
name so it resolves to the real file, and add .npmignore files so pnpm
packs the same files as npm.
2026-10-11 13:18:09 +02:00
BastiaanandClaude Opus 5.5 ae6e25d1b9 Add regression tests for #704 and #705 (#1060)
Covers the icon_size default of 64 in the export icon loaders and the
host recreating saves and script-output when an instance is assigned.
Both tests fail when the original fix is reverted.

Closes #710

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:57:00 +01:00
BastiaanandClaude Opus 5.5 839f9eb56c Use the config access API in the ctl config editor (#1059)
serializedConfigToString now asks a control-located config instance
whether each field is readable and writable instead of relying on a
hard-coded list per command. Hidden and unknown fields are skipped, and
field descriptions are written as comments above each value.

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

Fixes #1051

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:53:06 +01:00
Cooldude2606 490b10e07a Merge pull request #1058 from bbassie/fix/subscriber-account-update
Resend subscriptions when the account permissions change
2026-10-08 18:52:33 +01:00
bbassieandClaude Opus 5.5 74aa1323d8 Name the handleAccountEvent tests after the method
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:40:02 +00:00
bbassieandClaude Opus 5.5 2fc5a67dce Resend subscriptions when the account permissions change
EventSubscriber registers with the control's onAccountUpdate when it
has one and resends its subscription through handleAccountEvent. A lost
permission makes the request fail, a granted one syncs it again.

The web Control also reports the account on connect, so resends are
queued and sent once per tick. A failed subscription request now sets
synced to false and notifies callbacks, before the error never reached
the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:31:19 +00:00
Cooldude2606 8a170eb2ac Merge pull request #1057 from bbassie/fix/bounded-send-buffer
Limit unacknowledged messages buffered for a connection
2026-10-08 17:25:57 +01:00
Cooldude2606 208ecb826e Merge pull request #1056 from bbassie/fix/subscription-permission-revoke
Drop subscriptions when a user loses permission for them
2026-10-08 17:23:43 +01:00
Cooldude2606 ec1ce79be9 Merge pull request #1055 from bbassie/fix/subscription-limits
Limit the number of subscription filters per subscriber
2026-10-08 17:02:33 +01:00
Cooldude2606andClaude Opus 5.5 faaaf399cb Add a plugin migration guide for 2.0 (#1050)
* Add a plugin migration guide for 2.0

Covers the plugin API changes between 2.0.0-alpha.27 and 2.0.0: ESM,
entrypoint functions and hooks, loadedPlugins, permissions on the
plugin declaration, the ctl renames, staticRoot and optional peer
dependencies.

Document that from 2.0 major versions contain breaking changes for end
users while minor versions may contain breaking changes for the plugin
API, and fix stale examples and names in writing-plugins.md and
managing-a-cluster.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the fromContext class pattern in the migration guide

Plugins that still want a class for state use a private constructor,
an async static fromContext and a static WeakMap lookup, which also
replaces controller.plugins.get() for cross-plugin access. Check
loadedPlugins with Set.has on the info from pluginInfos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Attach hooks in fromContext and export it as the entrypoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:57:09 +01:00
bbassieandClaude Opus 5.5 d10da0f5fc Rename to checkLinkPermissions and stop log following on revoke
The old name read as an unconditional unsubscribe. Log following has
its own subscription outside SubscriptionController, so check
core.log.follow on permission changes too. Move the per-connection
work into one helper and split the tests into userPermissionsUpdated
and rolesUpdated, checking the account update event is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:21:02 +00:00
bbassieandClaude Opus 5.5 b4cbba1e9a Attach hooks in fromContext and export it as the entrypoint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:19:19 +00:00
bbassieandClaude Opus 5.5 ad4595a257 Make the send buffer limit a config and start a new session on overflow
Add controller.max_send_buffer_size in MiB, default 64 and 0 for no
limit. Close with 1013 instead of 1008 on overflow: clients treat 1008
as fatal, so a host would shut down and the web UI would log out. With
1013 the client resumes, the controller answers with invalidate since
the session has ended, and the client registers a new session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:18:02 +00:00
bbassieandClaude Opus 5.5 d2163cf6a0 Make the filter limit a restart setting with 0 for no limit
Lowering the limit at runtime would leave connections holding more
filters than allowed, so read it once when the controller starts.
Move the limit tests into the subscribe and replace groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:36 +00:00
BastiaanandClaude Opus 5.5 fe1391903a Redact credentials from logged HTTP request headers (#1054)
The controller HTTP access log and the express error log wrote the full
request headers at the http level, which both default log transports
keep. Clients send their access token in X-Access-Token or
Authorization: Bearer, so user and host tokens ended up in plain text
in controller and cluster logs, and the cluster log can be read back
over the API with core.log.query.

Mask the authorization, x-access-token, cookie and proxy-authorization
headers before logging.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:45:43 +01:00
Bastiaan 069f537bde Merge pull request #1053 from bbassie/fix/instance-save-name-traversal
Check save names in instance start and create save
2026-10-08 15:43:26 +01:00
bbassieandClaude Opus 5.5 235a15b9cc Limit unacknowledged messages buffered for a connection
Every message sent over a link is kept in the send buffer until the
other side acknowledges it with a heartbeat, so it can be resent if
the session resumes. Nothing forced a peer to acknowledge, so a client
that kept heartbeating without a sequence number while subscribed to a
busy event made the controller buffer messages until it ran out of
memory.

Store buffered messages serialised, track their total size, and close
the connection once it goes over maxSendBufferSize. The controller
sets this to 64 MiB for the connections it accepts. A client that far
behind could not resume in a reasonable time anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:15:27 +00:00
bbassieandClaude Opus 5.5 beec2f523a Drop subscriptions when a user loses permission for them
The permission for a subscription was only checked when subscribing,
so a control connection kept receiving events after the user's roles
or role permissions changed to no longer allow it.

Add SubscriptionController.unsubscribeUnpermitted which rechecks every
subscription of a link and removes the ones the user is no longer
permitted, and call it for the affected control connections from
userPermissionsUpdated and rolesUpdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:11:28 +00:00
bbassieandClaude Opus 5.5 690b79d2b7 Limit the number of subscription filters per subscriber
A subscriber could keep sending subscribe requests with new filters and
the controller would add every one of them to the subscriber's filter
set without bound, growing memory until it ran out.

Add controller.max_subscription_filters (default 10000) and reject
subscribe and replace requests that would leave a subscriber holding
more filters than that for one event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:10:03 +00:00
bbassieandClaude Opus 5.5 d3e9803069 Show the fromContext class pattern in the migration guide
Plugins that still want a class for state use a private constructor,
an async static fromContext and a static WeakMap lookup, which also
replaces controller.plugins.get() for cross-plugin access. Check
loadedPlugins with Set.has on the info from pluginInfos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:13:38 +00:00
Cooldude2606 ce6bfe282f Merge pull request #1052 from bbassie/refactor/migrate-plugins-2.0
Migrate bundled plugins to entrypoint functions
2026-10-08 11:12:21 +01:00
bbassieandClaude Opus 5.5 60dbb461e7 Convert subspace_storage to entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:47 +00:00
bbassieandClaude Opus 5.5 1ca0c658b9 Convert inventory_sync to entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:00 +00:00
bbassieandClaude Opus 5.5 b2705b8788 Convert player_auth to entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:00 +00:00
bbassieandClaude Opus 5.5 cfeb4b6a4a Convert global_chat, research_sync and statistics_exporter to entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:00 +00:00
bbassieandClaude Opus 5.5 bd609c3c54 Allow metrics hook handlers to return nothing
A handler that only updates gauges has nothing to yield. collect() already
dropped undefined results at runtime, now its type says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:00 +00:00
bbassieandClaude Opus 5.5 dab4dd4a4e Add test helpers for plugin entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:11:02 +00:00
bbassieandClaude Opus 5.5 5814b89821 Add a plugin migration guide for 2.0
Covers the plugin API changes between 2.0.0-alpha.27 and 2.0.0: ESM,
entrypoint functions and hooks, loadedPlugins, permissions on the
plugin declaration, the ctl renames, staticRoot and optional peer
dependencies.

Document that from 2.0 major versions contain breaking changes for end
users while minor versions may contain breaking changes for the plugin
API, and fix stale examples and names in writing-plugins.md and
managing-a-cluster.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:10:30 +00:00
Cooldude2606 c3337bdedb Merge pull request #1049 from ColumbusLabs/fix/796-config-editor-field-names
Fix CLI config editor field names
2026-10-07 20:01:18 +01:00
Cooldude2606 c11079260d Fix copy paste error in error message 2026-10-07 19:52:16 +01:00
Zachary S 7ee09ac790 Fix CLI config editor field names 2026-10-07 00:31:52 +00:00
Cooldude2606 b07c25aa7e Merge pull request #1014 from bbassie/feature/patched-scenario
Add create-scenario command to clusteriohost
2026-10-06 16:37:16 +01:00
Cooldude2606 6eb7e1a81f Merge pull request #1048 from ILLISIS/fix/load-json-object-throw-on-missing
Throw on a missing file when loadJsonObject is asked to (follow-up to #1046)
2026-10-06 16:35:56 +01:00
ILLISISandClaude Opus 5.5 6626b1ce7d Move the changelog entry to the PR description
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGUULoiHNbAegwMK4PSjHJ
2026-10-07 01:44:54 +11:00
ILLISISandClaude Opus 5.5 b2a426bb30 Throw on a missing file when loadJsonObject is asked to
Controller.loadJsonObject(path, true) returned {} for a missing file, so
loadPlugins' catch never ran and a plugin whose web build was expected but
missing was not reported (the second half of #1046; #1047 fixed the path).
With throwOnMissing set, every error now throws; without it only a missing
file is ignored, so a malformed JSON file throws again as before #1047.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGUULoiHNbAegwMK4PSjHJ
2026-10-07 01:21:11 +11:00
Cooldude2606 d89a6e33f3 Merge pull request #1044 from bbassie/feature/role-permission-sections
Add a permission group list to the role page
2026-10-05 01:30:20 +01:00
bbassieandClaude Opus 5.5 0684f4719e Hide the permission group list on narrow screens and let it scroll
Below the lg breakpoint the list is not rendered and the permission
groups take the full width. At md the site sider already takes 250px,
so a 200px list next to it left too little room for the permissions.

The list keeps its sticky position but is capped at the viewport height
minus its top offset and scrolls on its own when there are more groups
than fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:55:24 +00:00
bbassieandClaude Opus 5.5 2b272d230c Add a permission group list to the role page
A sticky list of permission groups sits left of the permissions. Clicking
an entry scrolls to that group, and its dot is blue when any permission
in the group is granted, grey otherwise. Same layout as the ExpCluster
scenario features page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:48:06 +00:00
Cooldude2606 7b2f7064b9 Fix lint error 2026-10-05 00:46:06 +01:00
Cooldude2606 4c0c89c5ff Fix broken manifest url resolution 2026-10-05 00:30:47 +01:00
Cooldude2606 d1b1f58f00 Fix broken manifest path resolution (#1047) 2026-10-04 20:42:57 +01:00
Bastiaan 793f23a390 Merge pull request #1045 from bbassie/feature/role-page-extension-slot
Add a role page extension slot above the permissions
2026-10-02 21:29:15 +01:00
Cooldude2606andClaude Fable 5.1 2efb53aab1 Add a request to mark hosts and the controller as needing a restart (#1043)
* Add a request to mark hosts and the controller as needing a restart

Not every plugin arrives through PluginInstallRequest. Plugins checked
out into external_plugins or installed by hand on the machine never
flag a restart, so the web UI shows nothing and tooling that acts on
the flag never fires.

HostMarkRestartRequiredRequest and ControllerMarkRestartRequiredRequest
set config.restartRequired, guarded by the existing restart
permissions. They are exposed as "Mark Restart Required" in the control
dropdown of the host and controller pages, hidden once the flag is set,
and as mark-restart-required ctl commands. The mark shows up on the
next system metrics poll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cover the mark-restart-required ctl commands

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 21:14:23 +01:00
Bastiaan 80d6bc8da6 Merge pull request #1042 from bbassie/fix/plugin-install-restart-required
Mark restart required after installing a new plugin
2026-09-26 21:10:15 +01:00
bbassieandClaude Fable 5.1 1299ea80b8 Cover the mark-restart-required ctl commands
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:31:20 +00:00
bbassieandClaude Fable 5.1 bca681b5ea Add a request to mark hosts and the controller as needing a restart
Not every plugin arrives through PluginInstallRequest. Plugins checked
out into external_plugins or installed by hand on the machine never
flag a restart, so the web UI shows nothing and tooling that acts on
the flag never fires.

HostMarkRestartRequiredRequest and ControllerMarkRestartRequiredRequest
set config.restartRequired, guarded by the existing restart
permissions. They are exposed as "Mark Restart Required" in the control
dropdown of the host and controller pages, hidden once the flag is set,
and as mark-restart-required ctl commands. The mark shows up on the
next system metrics poll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 19:52:03 +00:00
Hornwitser e2817eba39 Merge pull request #1041 from Hornwitser/test-fixes
Test improvements
2026-09-15 22:41:10 +02:00
Hornwitser 9fbff1e8e5 Ignore errors in concurrent safeOutput test
On Windows this test throws "EPERM: operation not permitted, rename
<path>" presumably because Windows prevents concurrent renames.  This
does not affect the test outcome, so ignore the error(s).
2026-09-15 22:18:16 +02:00
Hornwitser 0c4fac37b0 Move test/host.js into the test/host folder
This is clearly part of the host tests. It should be inside of the host
test folder.
2026-09-15 22:18:16 +02:00
Hornwitser 92d2662078 Move slowTest and externalTest to test/common.js
These are used outside of the integration tests, but including the
integration index file causes the very slow setup of the contorller
and host.  Avoid the integration setup costs when running commands
such as `pnpm mocha test/lib` by moving these utility functions to
the common test functions.
2026-09-15 22:18:16 +02:00
Hornwitser ea1cb67502 Setup logging transport in tests
Without a transport Winston will print annoying "Attempt to write logs
with no transports" warnings all over the place.  Setup a transport in
tests that cause logging to suppress this.
2026-09-15 22:18:16 +02:00
Hornwitser 2c52c265cf Require config files to be explicitly created (#1040)
The behaviour of creating the config file if it's missing while
convenient causes confusion and problems when the tools are run
in the wrong folder.  They end up creating a shadow installation
and misdirect the user with nonsensical error messages, instead
pointing to the real problem which is that the tool can't find
the configuration needed to actually run.

Require a config file to be present at startup and refuse to run if this
file is missing.  To support the odd use-case of creating the config
when missing a --create-config flag is added. To conveniently generate
a config file to edit out from the "config create" command is added.
2026-09-15 22:16:54 +02:00
Hornwitser 5df5af7ca8 Rename Ctl's usage of "control" to "ctl" (#1039)
Ctl ended up calling its main class for "Control" for historical
reasons, along with its config file and plugin entrypoint.

The plugin entrypoint was renamed to ctl in 808eda0513.  This
finishes that renaming by also renaming the main class, default config
file, config-control command, and main entrypoint.

The config-control command is renamed to config to be consistent with
the host and controller's config commands.

The term "control" is still kept in the config and message locations as
a common specifier for both ctl and web_ui.
2026-09-14 20:18:41 +02:00
Cooldude2606 1d1d9bb1c4 Merge pull request #1031 from bbassie/fix/stop-before-rcon-ready
Resolve instance stop when Factorio exits before RCON is ready
2026-09-13 18:35:14 +01:00
Cooldude2606 a6f2c2829e Merge pull request #1036 from bbassie/fix/host-save-details-ownership
Reject save detail updates for instances not on the sending host
2026-09-13 18:29:56 +01:00
bbassieandClaude Opus 5 2a967e84c5 Reject the RCON wait instead of resolving with an error
_waitForRconReadyOrExit resolved with an Error to signal the process
exited before RCON came up, which is an odd shape for a promise.  It now
resolves when RCON is ready and rejects otherwise, with the error emitted
with the exit, or "RCON connection lost" when there was none.

Observable behaviour is unchanged.  sendRcon rejects with the same
messages as before and stop ignores the rejection, as stopping is done
once the process is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:29:41 +00:00
Cooldude2606 0065d07fe3 Merge pull request #1028 from bbassie/fix/instance-config-change-crash
Log errors from instance config change handlers instead of exiting the host
2026-09-13 18:16:59 +01:00
Cooldude2606 16f28ec5c7 Merge pull request #1037 from bbassie/feature/changelog-stats
Add contributors title and statistics to the changelog script
2026-09-13 18:14:21 +01:00
bbassie 0273558edc Merge remote-tracking branch 'upstream/master' into fix/stop-before-rcon-ready 2026-09-13 17:10:02 +00:00
bbassie 97bab72eb9 Merge remote-tracking branch 'upstream/master' into fix/host-save-details-ownership 2026-09-13 17:10:00 +00:00
bbassieandClaude Opus 5 70c823ae9f Correct misaligned instance names on forwarded log messages
The instance name on a log line is whatever the host sent, so a host can
attribute a line for one of its own instances to the wrong name. Compare
it with the name the controller has on record and replace it when they
differ.

Host connections can live for days, so the warning suppression expires
after five minutes instead of latching for the life of the connection,
and the bogus instance and misaligned name warnings share it so the two
together cannot spam the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:09:53 +00:00
bbassieandClaude Opus 5 229bbf8eb8 Report why the server exited from sendRcon
Waiting for exit alongside rcon-ready made sendRcon reject with the flat
"RCON connection lost" for every case, including the unexpected exits
that used to reject with the EnvironmentError saying why the server went
away, as events.once rejects on error events.

Have the paired wait capture the error emitted with the exit and hand it
back, so sendRcon throws that when there is one and falls through to the
old message when there is not, which is the kill path where no error is
emitted. stop() ignores the error as its job is done once the process is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:09:51 +00:00
bbassie 767f9e7fe3 Merge remote-tracking branch 'upstream/master' into fix/instance-config-change-crash 2026-09-13 17:09:02 +00:00
bbassieandClaude Opus 5 a76b742294 Invoke the config changed hook for every field
The fieldChanged handler repeated the hook invoke in each branch, and
factorio.shutdown_timeout had no invoke at all, so plugins were never
told about a change to it. Do the field specific side effect, then
invoke the hook once for every field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:08:53 +00:00
Cooldude2606 9d04681a42 Change format output 2026-09-13 18:01:23 +01:00
Cooldude2606 775dd8126f Merge pull request #1029 from bbassie/fix/instance-assign-failure
Keep instances assignable after a failed assign or create
2026-09-13 17:47:48 +01:00
bbassieandClaude Opus 5 0f4671afd0 Add contributors title and statistics to the changelog script
Puts the contributors under a "Contributors" title on a single comma
separated line, and prints commits, merged pull requests, files changed
and line additions and deletions since the last release after them.

The compare API lists at most 300 files, so the file and line counts
come from the diff media type of the same comparison instead. To fetch
it, githubFetch now lets the caller override the Accept header and
checks the Content-Type against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:29:38 +00:00
bbassie daa39fd7ec Merge remote-tracking branch 'upstream/master' into fix/instance-assign-failure 2026-09-13 14:43:43 +00:00
Cooldude2606andClaude Opus 5 a28e2bf9f6 Terminate control connections of deleted users (#1030)
* Terminate control connections of deleted users

ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Terminate deleted user sessions immediately

Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Handle SessionLost when deleting your own account

Deleting your own account now terminates the session immediately, so the
web UI received a SessionLost rejection and reported "Error deleting
user" for a delete that had succeeded. Use the same branch revoke does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-13 15:31:46 +01:00
Cooldude2606 c355fc6888 Merge branch 'master' into fix/terminate-deleted-user-sessions 2026-09-13 15:18:25 +01:00
d32d857eb3 Re-apply debug dump ignore flag on control connection resume (#1035)
handleDebugDumpWsRequest sets clusterio_ignore_dump on the current
socket so the WsServer send wrapper does not echo the dumper's own
frames back into debugEvents. The flag was only set on the connector
"connect" event, but WsServerConnector.continue() installs a fresh
socket and emits "resume", not "connect", so a resumed session lost
the flag.

After a resume the next outgoing frame was echoed to debugEvents,
ws_dumper sent a DebugWsMessageEvent, the wrapper echoed that too,
and so on: infinite synchronous recursion ending in a RangeError:
Invalid string length from JSON.stringify in link.ts sendEvent. That
uncaught error made handleUnhandledErrors exit the controller.

Repro: admin sends DebugDumpWsRequest, the control connection drops
and resumes (e.g. a network blip), controller crashes on resume.

Set the flag on "resume" as well as "connect".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-13 15:13:24 +01:00
bbassieandClaude Opus 5 cf4e85c6ee Guard forwarded log messages by instance assignment
Log messages share the root cause with the save details updates fixed in
the previous commit: the controller trusted host supplied instance
fields. LogMessageEvent only declares level and message in its schema,
but the Ajv instance is built without removeAdditional and the schema
does not set additionalProperties, so instance_id and instance_name sent
by a host survive validation and pass through fromJSON untouched. A host
could tag a log line with an instance assigned to another host and have
the controller store and serve it as that instance's output.

Lift the assignment check out of handleInstanceSaveDetailsUpdatesEvent
into a private method and use it from both handlers.

Strip the instance fields instead of dropping the line. A host racing a
reassignment does emit instance log lines for an instance the controller
has already moved, and dropping those loses real output exactly when a
migration is going wrong. The line is still genuine output from the host,
so it is recorded as a host level message, which grants the host nothing
it could not already log directly. Warn once per connection so a
misbehaving host cannot flood the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:11:23 +00:00
BastiaanandClaude Opus 4.8 fc73433ffb Stop host crash on malformed action log lines (#1034)
_watchServerLogActions dereferenced two regex results with ! and
threw on null. The name regex /^([^ ]+)/ returns null when a line
classified as an action starts with a space, and the BAN reason
regex /\. Reason: (.+)\.$/ returns null for a [BAN] line with no
". Reason: X." tail. The output listener runs synchronously off the
stdout LineSplitter, so the throw becomes an uncaughtException and
takes the whole host down with every instance on it.

Repro: instance with enable_save_patching off and enable_script_commands
on; a control client sends /sc print("2000-01-01 00:00:00 [BAN] evil").
The raw print reaches stdout, parses as a dated BAN action, and the
reason deref throws.

Skip the line when the name regex misses and default the ban reason
to empty when the tail is absent. Apply the same name guard to
_watchPlayerPromote. The autosave ![1] in server.ts is safe because
its filter regex already guarantees the capture.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:53:49 +01:00
BastiaanandClaude Opus 4.8 8239a96509 Deny instead of crashing when a control permission check throws (#1033)
A permission function runs during ingress permission checking, before
the request body is validated. UserBulkImportRequest.permission read the
raw, unvalidated message.data and threw a bare Error for an unknown
importType. That error is not a PermissionError, so _processMessage
rethrew it to the connector "message" listener, which emitted "error" on
the connector. Controller-side connectors have no "error" listener, so
Node rethrew and handleUnhandledErrors exited the process.

Repro: a control client with client-side validateSent disabled sends
UserBulkImportRequest with importType "foo"; the client gets a normal
RequestError response and the controller then logs a fatal uncaught
exception and exits.

Fix both layers: _processMessage now logs and drops an unexpected error
from the permission check (control connections only, where a response
was already sent) instead of rethrowing, and UserBulkImportRequest
throws a PermissionError for an unknown importType so it is denied
cleanly. A genuine PermissionError still denies silently as before.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:51:16 +01:00
bbassieandClaude Opus 5 49e8d6ecc5 Fix sendRcon hanging if Factorio exits before RCON connects
sendRcon waits for the rcon-ready event when RCON has not connected yet,
which is the same root cause as the stop() hang. events.once rejects on
error events, but _watchExit skips emitting error while the state is
stopping, and kill() sets that state before sending SIGKILL. exportData
does exactly that, so an RCON call racing the export never settles and
the request stays pending until the session closes.

Wait for exit as well, which leaves the existing RCON client check to
reject the call with "RCON connection lost" instead. Both call sites now
share a helper as the paired wait is identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:49:58 +00:00
BastiaanandClaude Opus 5 558999faa8 Parse the Map Settings field in the load scenario modal (#1032)
The Map Settings block parsed values.mapGenSettings, so filling in only
Map Settings failed with "undefined" is not valid JSON and the modal
would not submit, while filling in both sent the map gen settings JSON
to Factorio as --map-settings.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:45:50 +01:00
bbassieandClaude Opus 5 c9e8aeee5c Handle SessionLost when deleting your own account
Deleting your own account now terminates the session immediately, so the
web UI received a SessionLost rejection and reported "Error deleting
user" for a delete that had succeeded. Use the same branch revoke does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:34:58 +00:00
bbassieandClaude Opus 5 1da7c1da67 Terminate deleted user sessions immediately
Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:31:09 +00:00
bbassieandClaude Opus 5 fb71ee7282 Terminate control connections of deleted users
ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:30:45 +00:00
bbassieandClaude Opus 5 96362a2835 Wait for transitory instance statuses to settle
The config changed handler ran updateFactorioSettings and
updateFactorioWhitelist as soon as the field changed. While the instance
is starting, stopping, creating_save or exporting_data the server is not
ready for RCON, so the update threw and was only logged. The three list
update events pushed from the controller had the same problem, except
Link._processEvent swallowed the rejection and the update was lost
without a trace.

Add _waitForSettledStatus, which resolves once notifyStatus lands on
running or stopped. notifyStatus is the only place _status changes and
notifyExit always ends on stopped, so a waiter cannot hang. The settings,
whitelist and list updates now go through it.

Nothing is applied when the instance settles on stopped, because there is
no server to apply it to. factorio.settings is written to
server-settings.json and the lists to the list files on the next start,
and enableWhitelist is read from the config when the next Instance is
constructed.

The plugin hook now runs immediately for the two config fields instead of
being chained onto the update. Every other field already invokes it right
away, and waiting for the instance to settle would hold the notification
back for the whole startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:20:52 +00:00
Cooldude2606 3c692c4321 Merge branch 'master' into fix/instance-assign-failure 2026-09-13 14:04:45 +01:00
BastiaanandClaude Opus 5 5b63fd27fe Fix listener leaks when a control connection closes (#1027)
The close handler removed the log subscription transport from the global
lib logger, but updateLogSubscriptions adds it to the controller's
clusterLogger. Every control connection that subscribed to logs stayed
attached to clusterLogger after closing, keeping the connection alive and
running every later cluster log line through its filter.

handleDebugDumpWsRequest added a new debugEvents listener on each call
and overwrote ws_dumper, so the close handler only removed the last one.
Repeated requests stacked listeners, triggered MaxListenersExceededWarning
after 11 calls, and left the earlier listeners behind. Ignore the request
if the connection is already dumping.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:44:18 +01:00
bbassieandClaude Opus 4.8 5b769adcc5 Reject host save updates for foreign instances
handleInstanceSaveDetailsUpdatesEvent trusted the instanceId in the
event and in each SaveDetails without checking the instance was assigned
to the sending host. InstanceSaveDetailsUpdatesEvent has host in its src
list and validateIngress only checks the source host id, not the
payload, so any authenticated host could corrupt another instance's
controller-side save records.

Repro: a host owning nothing sends the event with a SaveDetails for
instance 1002 (on another host) to inject a bogus save, or sends empty
updates with instanceId 1002 to make the delete-missing sweep wipe that
instance's save list.

Guard both paths: bail if event.instanceId names an instance not
assigned to this host, and drop the whole event if any update is for an
instance not assigned here. Matches handleInstanceStatusChangedEvent's
bogus-update warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 02:39:12 +00:00
bbassieandClaude Opus 5 073ddf1421 Fix stop hanging if Factorio exits before RCON connects
stop() waits for the rcon-ready event when RCON has not connected yet,
but nothing emits it once the process is gone, so the promise never
settles and the stop request stays pending until the session closes.
Wait for exit as well and drop the other listener when either fires.

Repro: send InstanceExportDataRequest to a stopped instance, then
InstanceStopRequest while the status is exporting_data. Instance.stop()
sees the server as running and calls server.stop(), then exportData
kills the process before RCON is up. The export finishes and the
instance reports stopped, but the stop request never gets a response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:52:22 +00:00
bbassieandClaude Opus 5 fbdd647168 Keep instances assignable after a failed assign or create
assignInstance sets instance.assigned_host to the new host before
sending InstanceAssignInternalRequest. If the host fails that request
the controller kept the new host id while the host never added the
instance. Assigning to the same host again then returned early as a
no-op. Repro: make the host's instances directory read-only, assign
instance 3001 to host 10 (EACCES on mkdir), make it writable, assign
again (ok, nothing happens), start (instance does not exist on host).
Now a failed send leaves the instance unassigned and rethrows.

InstanceCreateRequest applied instance.assigned_host from the request
config, creating an instance assigned to a host that never heard of
it. Repro: InstanceCreateRequest({"instance.id": 4001, "instance.name":
"c1", "instance.assigned_host": 10}). Reject a non-null value with the
same error the config set requests use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:36:54 +00:00
bbassieandClaude Opus 5 18aed41889 Log errors from instance config change handlers
The fieldChanged handler in the Instance constructor ran
updateFactorioSettings and updateFactorioWhitelist with only a finally
attached. A rejection from either went unhandled, and the host exits on
unhandled rejections, taking every instance on it down.

Two ways to hit it against a real host:

- Start an instance and immediately toggle factorio.enable_whitelist.
  updateFactorioWhitelist calls sendRcon while the server state is still
  "new" and it throws "Expected state running,stopping but state is new".
- Set factorio.settings tags to [1] on a running instance. applyAsConfig
  called tag.replace outside its try block and threw
  "tag.replace is not a function".

Catch and log both on the instance logger before invoking the hook, and
convert tags with String() so non-string entries no longer throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:35:53 +00:00
BastiaanandClaude Fable 5.1 f4aa894fe7 Give each safeOutputFile call its own temporary file (#1026)
Two writes to the same file at the same time shared one temporary name, so the second write clobbered the first's temporary and the first rename left the second call with nothing to rename, failing with ENOENT. The host does exactly that when it saves an instance config while the instance saves it too.

The temporary now carries a random tag and is created exclusively, so concurrent writers never touch each other's file. Since the temporary is always new, the mode is applied on creation and the chmod for reused leftovers goes away. A failed rename removes its temporary instead of leaving it for the next write to overwrite.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:37:26 +01:00
Cooldude2606 a7024c8f72 Merge pull request #1025 from bbassie/fix/esm-coverage
Collect coverage with c8 instead of nyc
2026-09-13 01:15:12 +01:00
Cooldude2606 820bafa11f Merge branch 'master' into fix/esm-coverage 2026-09-13 01:08:43 +01:00
Cooldude2606 82a9d0ee9e Merge pull request #1024 from bbassie/ci/pnpm-store-cache
Cache builds in CI and skip them where unused
2026-09-13 01:06:13 +01:00
Cooldude2606 5a3a0a189c Merge pull request #1023 from bbassie/chore/dependency-updates
Bump dependency versions
2026-09-13 00:41:25 +01:00
bbassieandClaude Fable 5.1 fd05ea0e9c Move shared dependencies into the workspace catalog
yargs, @types/yargs, semver and @types/semver are used by several packages at the same version. Also point the remaining direct references to @swc/core and react-router-dom at their existing catalog entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:36:25 +00:00
bbassieandClaude Fable 5.1 cf6c0ae873 Collect coverage with c8 instead of nyc
nyc instruments through the CommonJS require hook, so since the ESM migration (#984) it has seen nothing and Codecov has reported 0%. c8 reads V8's own coverage, which covers ES modules and child processes alike, and maps it back to the TypeScript sources through the emitted source maps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:29:45 +00:00
bbassieandClaude Fable 5.1 f3daaf7b15 Bump dependency versions
Raise every caret range to the current latest within its major, in the
root package.json, the pnpm-workspace.yaml catalog, the per-package
package.json files and the create plugin template.

Majors taken: eslint 10, @eslint/js 10, globals 17, nyc 18,
@types/express 5, @types/node 24, finalhandler 2, pidusage 4,
style-loader 4, css-loader 7, yargs 18 (all packages), chalk 6.

Majors skipped: mocha 12 (integration suite fails under it, 59 tests,
starting with a 30 s timeout in clusteriocontroller run; kept at
^11.8.0), inquirer (10+ is a rewrite on @inquirer/prompts where
ui.Prompt.prototype.close no longer exists, so the win32 readline
workaround in create.js would have to go and cannot be verified here),
react/antd/react-router-dom/@types/react, typescript 7,
@sinclair/typebox, webpack-merge (exact pin), webpack-dev-middleware,
webpack-manifest-plugin, winston-daily-rotate-file.

Code adaptations: the two /api/stream/:id handlers in routes.ts are
typed Request<{ id: string }> because @types/express 5 types
req.params values as string | string[]; eslint.config.mjs adds
newIsCapExceptions: ["constructor"] because eslint 10.10 new-cap now
reports `new this.constructor()`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:24:13 +00:00
bbassieandClaude Fable 5.1 992fc0fed7 Cache build output and skip builds where unused
The prepare scripts (tsc --build and webpack) are most of the install time in every job, so their output is cached between runs. tsc's tsbuildinfo tracks file hashes and webpack's filesystem cache keeps its node_modules modules, so a restored cache makes both incremental.

The lint and build-mod jobs never read the compiled output: eslint is not type-aware here and build_mod.js is plain JavaScript. They now install with --ignore-scripts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:16:12 +00:00
Cooldude2606 68d47ec507 Merge pull request #1022 from bbassie/chore/bump-deps
Bump pnpm, GitHub Actions and CI Factorio version
2026-09-13 00:08:55 +01:00
bbassieandClaude Fable 5.1 b38211469a Cache the pnpm store between CI runs
The lockfile is not committed so the usual lockfile hash key does not work. Instead the store is snapshotted whenever a package.json changes or a week passes, and every run restores the most recent snapshot through a prefix restore key. pnpm still resolves the latest versions within the ranges; the cache only saves the tarball downloads. A composite action holds the setup so the four jobs share it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:02:41 +00:00
bbassieandClaude Fable 5.1 9bd12d70e9 Bump Dockerfile base image to Node 24
Node 20 reached end of life in April 2026.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 c0be963f9c Test against Factorio 2.1.17 and emmylua 0.25.1
2.1.17 is the current experimental release. The lint gate still reports 0 diagnostics over 28 files with emmylua_check 0.25.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 74e8d89081 Bump GitHub Actions to current majors
checkout, setup-node, upload-artifact and codecov-action to v7, pnpm/action-setup to v6 and action-gh-release to v3. All of them now run on the node24 actions runtime. No inputs changed for how the workflows use them. pnpm/action-setup v6 is also what makes trusted publishing work with pnpm 12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
bbassieandClaude Fable 5.1 8468f8505a Bump pnpm to 12
pnpm 11 removed onlyBuiltDependencies in favour of allowBuilds and stopped reading non-auth settings from .npmrc, so auto-install-peers moves to pnpm-workspace.yaml. The native publish flow in pnpm 11+ keeps npm trusted publishing working as long as pnpm/action-setup is v6 or later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:59:23 +00:00
Cooldude2606 8be251447a Merge pull request #1018 from bbassie/feature/plugin-install
Add plugin install command to controller, host and ctl
2026-09-12 23:35:26 +01:00
Cooldude2606 008e1d7805 Merge pull request #1019 from bbassie/feature/metrics-auth
Require a token to scrape /metrics
2026-09-12 23:34:59 +01:00
bbassieandClaude Fable 5.1 310071df1b Use a service account name for the metrics scraper
Any valid Factorio player name can be claimed by a player joining the
cluster, so the docs now create the scraper user as #prometheus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:28:43 +00:00
bbassieandClaude Fable 5.1 87aa7b3b4c Require a token to scrape /metrics
The metrics endpoint fans out a gather to every connected host, so
anyone who could reach the controller could read cluster statistics and
put load on all hosts at will. Scraping now needs a user token holding
the new core.controller.metrics permission. The token is accepted via
Authorization: Bearer as well as X-Access-Token because Prometheus can
only send the former. Concurrent scrapes share one gather so hosts see
at most one metrics request at a time.

The three connection gauges in controller.ts now no-op before the
controller exists, like the other gauges there, so the endpoint can be
tested against the mock controller.

Closes #1009

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:28:43 +00:00
bbassieandClaude Fable 5.1 187138bec5 Merge upstream/master and address review
Resolves the ESM conflicts in patch.ts, Instance.ts and host.ts. The
module gathering in Instance now goes through patch.loadModules with
the instance's loadedPlugins, and imports use explicit extensions.

Review changes: the existing-output check only treats ENOENT as
"does not exist" and rethrows anything else, a --zip option keeps the
output as a zip file for transfer (extracting stays the default), the
--scenario option describes that it takes a path and what the default
is, and the docs note that IPC is not available outside Clusterio.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:21:46 +00:00
bbassieandClaude Fable 5.1 2776f3c37a Convert shared_commands test to ES module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 21:58:20 +00:00
Cooldude2606 2b981a5734 Merge pull request #1020 from bbassie/refactor/plugin-hooks-v2
Add typed hook system for plugins
2026-09-12 22:57:14 +01:00
89c51f0f74 Document missing config options (#995)
configuration.md was missing most options added over the last few years. Add the controller, host and instance options that weren't listed, mark the internal ones, note the validation rule under each field that has one, and fix entries that had gone stale: the tls defaults, the proxy_stream_timeout name, game port assignment from host.factorio_port_range, the three sync list modes, and the player_online_autosave_slots heading.

Plugin options live in the plugin READMEs, so add the missing ones for inventory_sync, player_auth and subspace_storage there. config-system.md gets the field definition properties it didn't list yet.

Co-authored-by: bbassie <fqv4b97f@bastiaanbreemer.nl>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:47:13 +01:00
Cooldude2606 e6b642fc19 Merge pull request #1021 from Hornwitser/package-dependencies
Fix plugin dependencies
2026-09-12 22:11:06 +01:00
Cooldude2606 512f62c22b Merge branch 'master' into feature/plugin-install 2026-09-12 22:10:52 +01:00
bbassie db84455c34 Merge remote-tracking branch 'upstream/master' into refactor/plugin-hooks-v2
# Conflicts:
#	packages/lib/src/data/messages_plugin.ts
#	packages/web_ui/src/components/PluginViewPage.tsx
#	test/lib/plugin.js
2026-09-12 18:07:12 +00:00
bbassieandClaude Fable 5.1 7c0f77499c Address review on plugin install
Rethrow errors other than ENOENT and ENOTDIR when reading the package
spec as a directory, mark the tests that run npm as slow so FAST_TEST
skips them, and use ./external_plugins/my_plugin in the README examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 17:59:01 +00:00
Hornwitser d30e06b268 Set controller, host and ctl as optional peers
Configure the controller, host and ctl packages as optional peer
dependencies to prevent them being pulled in and installed by plugins in
environments that don't have them.  This means that a host only install
with a plugin that uses both host and controller will not cause the
controller package to be installed as well.
2026-09-12 16:19:03 +02:00
Hornwitser 4ac3d2c868 Remove @clusterio/web_ui from peer dependencies
The web_ui package is only used when building the web interface.  This
only happens during development and is thus adequatly covered by the
existing entry of web_ui in the devDependencies specification.

Removing web_ui from peer dependencies prevents it from being pulled in
by npm during a regular installation.  Since it's never used in a
regular installation this removed a whole lot of bloat.
2026-09-12 15:46:16 +02:00
bbassieandClaude Fable 5.1 8363b221bb Address review on plugin docs and web plugins map
Point the invalid configuration section at the features flags and
config validators instead of throwing from the entrypoint, use const in
the examples, add a metrics hook example including a returned registry,
and mark Control.plugins as deprecated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 08:29:16 +00:00
Cooldude2606 9f67d7ab76 Merge pull request #998 from bbassie/feature/update-all
Add update all for the controller, hosts and plugins
2026-09-12 02:19:04 +01:00
Cooldude2606 c2325a8b6e Add missing as const for permission string 2026-09-12 02:12:49 +01:00
Cooldude2606 06a6a7edf3 Merge branch 'master' into feature/update-all 2026-09-12 02:07:54 +01:00
BastiaanandClaude Fable 5.1 1a10b38743 Only report a missing plugin web build when one is expected (#1013)
Plugins with only host, instance or ctl code have no reason to ship a
webpack build, yet the plugin list showed "Missing dist/web/manifest.json"
as an error for them and the controller logged a warning on every start.

Decide whether a build is expected from the plugin declaration, using the
same rule the create tool uses to decide when to generate one: a web or
controller entrypoint, or any config fields. Plugins outside that rule get
no bundle path and no error, the web UI skips loading them, and the plugin
pages show them as having no web module instead of an error or a prompt to
reload.

Fixes #1004

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:59:16 +01:00
BastiaanandClaude Fable 5.1 9c97eeb597 Refuse mod packs for a newer Factorio than the instance runs (#1015)
The mod pack's Factorio version ends up in mod-settings.dat. Factorio
refuses to read that file when the version is newer than the game and
falls back to default settings, which can wreck the save. Check the
versions in syncMods before touching the mods folder.

Closes #1006

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:53:45 +01:00
Bastiaan 5b0f629b6e Merge pull request #1016 from bbassie/fix/improve-logging
Add context to connection and response log messages
2026-09-12 01:51:39 +01:00
Cooldude2606andClaude Fable 5.1 a80b035a0a Send only recipe notification changes on inventory download (#997)
* Send only recipe notification changes on inventory download

Lua includes its current cleared recipe list in the download request. The instance plugin diffs it against the stored list and sends back the names to add and remove instead of the full list. Lua rebuilds the full list from the snapshot kept in the download record and runs the existing deserializer, so the end state is unchanged.

Also stop the serializer from appending to the stored failed recipe list in place, which grew the list on every upload without a download in between.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test the inventory sync download handler

Covers the empty response, the full list without a snapshot, the delta with a snapshot, and the IPC registration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Apply recipe notification deltas directly in Lua

Review feedback: the download field now always carries the delta and the deserializer applies it against the current state instead of rebuilding the full list first. The previous failed recipe names are passed in so they survive the round trip. The version check moved to the call site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Fix failed not being cleared

* Send only the local state on download init

The current state sent with the download request no longer includes the failed names from the previous deserialization. Stored names unknown on this instance always end up in the add list, so the deserializer recomputes the failed list from that on every download and needs no previous state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Convert inventory sync test to ES module

Master migrated to ESM after this branch was created, so the CommonJS test failed to load on the merge commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-12 01:49:40 +01:00
BastiaanandClaude Fable 5.1 d4b6df4f63 Create config files with mode 0o600 (#1017)
Config files hold the auth secret and tokens, so the default 0o644 lets
every user on the system read them. Config.save, the ctl config written
by the controller bootstrap, ctl host create-config and the config
migration in create now pass mode 0o600.

safeOutputFile also applies the mode to the temporary file it writes,
since writeFile only honours mode on creation and a leftover temporary
from a failed write would otherwise keep its old mode.

Closes #1005

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:49:14 +01:00
7122cbccf8 Add typed hook system for plugins
Replace invokeHook and the per plugin on* methods with AsyncHook
collections on the controller, host, instance, clusterioctl and web
control objects. Plugin entrypoints export a default function that
receives a load context and attaches handlers to the hooks it needs.
Class exports still load through fromContext with a deprecation
warning. The create templates and docs use the entrypoint function.

This is clusterio/clusterio#921 recreated on top of master after the
ESM migration and the PluginDeclaration permissions change.

Closes #730

Co-Authored-By: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 22:49:09 +00:00
bbassieandClaude Fable 5.1 8c20af6474 Convert inventory sync test to ES module
Master migrated to ESM after this branch was created, so the CommonJS test failed to load on the merge commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 22:29:04 +00:00
8c03ac15ab Define permissions on PluginDeclaration (#988)
Adds a permissions array to PluginDeclaration, a type-level Permissions
registry that plugins extend with module augmentation, and
registerPluginPermissions() called from all entrypoints. checkPermission
and the web UI hasPermission helpers now take PermissionName so typos are
compile errors. Core definitions move into a corePermissions array that
the interface derives its keys from.

Closes #987

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-11 23:28:30 +01:00
bbassie 1163ccffe4 Merge remote-tracking branch 'upstream/master' into feature/recipe-notification-delta 2026-09-11 22:28:01 +00:00
bbassieandClaude Fable 5.1 b1e79cf86d Send only the local state on download init
The current state sent with the download request no longer includes the failed names from the previous deserialization. Stored names unknown on this instance always end up in the add list, so the deserializer recomputes the failed list from that on every download and needs no previous state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 22:20:56 +00:00
Cooldude2606 2aee5379b5 Merge pull request #984 from Hornwitser/migrate-to-esm
Migrate to ESM
2026-09-11 23:14:50 +01:00
Cooldude2606 68ce13ead4 Fix failed not being cleared 2026-09-11 22:46:44 +01:00
Hornwitser 5dcaca4d0b Migrate to ECMAScript Modules
Migrate the usage of CommonJS specific code and imports to ESM.  Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
  equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.

Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
2026-09-11 22:43:57 +02:00
bbassieandClaude Fable 5.1 bb36af08fe Apply recipe notification deltas directly in Lua
Review feedback: the download field now always carries the delta and the deserializer applies it against the current state instead of rebuilding the full list first. The previous failed recipe names are passed in so they survive the round trip. The version check moved to the call site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 19:26:38 +00:00
BastiaanandClaude Fable 5.1 897c18bd08 Resolve numeric names in ctl id lookups (#989)
resolveHost, resolveInstance, resolveModPack and retrieveRole treated any
integer like argument as an id and never looked it up as a name. An
instance created as "10" could not be referenced by name afterwards. The
lookup now treats the argument as an id only if an entry with that id
exists, and otherwise falls back to a name match.

Closes #886

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:25:45 +01:00
Hornwitser b69111dda3 Fix obsolete usage of EventSubscriber in template
This functionality was moved to the MapSubscriber class in e49b82842d.
2026-09-11 20:18:49 +02:00
Hornwitser a025d01294 Fix mainBundle being passed as array in dev mode
Values in the assetsByChunkName object are arrays not strings.  This
caused the dev mode to fail after 2f4e0c4418 added an additional step
filtering out the static/ prefix from what it expected to be a string.
2026-09-11 19:59:41 +02:00
Hornwitser 9d061a0f53 Bump webpack version
Use the latest version of webpack and webpack-cli.  It's possible that
using ESM imports + having an outdated webpack-cli caused issues.
2026-09-11 19:51:16 +02:00
Hornwitser 511b8b7d97 Remove global_chat dependency in restart test
Use a fictional restart_test mock plugin added as a before step instead
of global_chat for the .checkRestartRequired() test.  Prevents the test
from failing if the integration tests are not also run.

Also fixes the fixture not accounting for setting load_plugin causing
restartRequired to be set to true due to the integration tests having
already set it to the same value.
2026-09-11 19:51:16 +02:00
Hornwitser 30083b96c1 Add packagePath to plugin info
Provide the absolute path to the package.json file in the resolved
plugin info to simplify the logic around loading files from it.
2026-09-11 19:51:15 +02:00
Hornwitser d6dce12acc Use factory pattern for yargs
The ESM import of yargs does not support the singleton way of using
yargs, instead we have to call the yargs function to get a parser that
can be augmented with the options.

The .argv property has also been deprecated. Replace it with a call to
.parse() instead.
2026-09-11 19:51:15 +02:00
bbassieandClaude Fable 5.1 5606d5d553 Add plugin install command to controller, host and ctl
Installing a plugin used to take two steps, npm install followed by plugin
add, and both silently did their work in whatever directory they were run
from. The new plugin install <package> subcommand runs npm install --save
and then discovers the plugin through the existing loadPluginList logic,
so the plugin list is updated in the same step.

The command refuses to run outside a Clusterio installation, detected by
the package.json the installer writes or one that depends on a Clusterio
package. plugin add with a bare package name warns in the same situation.

Reinstalling a plugin that is already listed reports it as such instead of
failing. Installing a package without the clusterio-plugin keyword fails.

Fixes #1008

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 00:36:47 +00:00
bbassieandClaude Fable 5.1 1072e5f1de Cover the create-scenario command handler and module loading
Adds tests for handleCreateScenarioCommand using a fake Factorio
install with the test scenario as freeplay, for loadModules skipping
plugins without a module and rejecting a name clash with a bundled
module, and an integration test running clusteriohost create-scenario.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 00:36:35 +00:00
bbassieandClaude Fable 5.1 c419f3e9b2 Add create-scenario command to clusteriohost
Creates a scenario directory with the Clusterio modules patched into it
so the modules can be used outside of Clusterio. The command reuses the
save patching by zipping the scenario, running patch() on it and
extracting the result. Defaults to the freeplay scenario from the
Factorio install in the host config and all plugins enabled there.

Module loading in Instance.prepareSave is moved to patch.loadModules so
the command and the instance share it, and findVersion is exported from
server.ts so the command can find the freeplay scenario.

Zipped scenarios were tried as an output form but Factorio does not
pick them up from the scenarios folder, so only directories are written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 00:16:29 +00:00
bbassieandClaude Fable 5.1 637ec0f901 Cover the --all flag on the ctl update commands
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:51:13 +00:00
bbassieandClaude Fable 5.1 7ff2092547 Add update all for the controller, hosts and plugins
Updating a single plugin can leave the installation with multiple
versions of @clusterio/lib. Passing the core package and every npm
installed plugin to a single npm update call lets npm resolve them
together.

UpdateAllRequest targets the controller or a host, requires both the
core update permission and core.plugin.update, and is gated by both
allow_remote_updates and allow_plugin_updates. The web UI Updates modal
gets an Update All action and ctl gets --all on the update commands.

Closes #910

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:40:32 +00:00
bbassieandClaude Fable 5.1 1272c0e8e4 Test the inventory sync download handler
Covers the empty response, the full list without a snapshot, the delta with a snapshot, and the IPC registration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:18:24 +00:00
bbassieandClaude Fable 5.1 0d6beeba47 Send only recipe notification changes on inventory download
Lua includes its current cleared recipe list in the download request. The instance plugin diffs it against the stored list and sends back the names to add and remove instead of the full list. Lua rebuilds the full list from the snapshot kept in the download record and runs the existing deserializer, so the end state is unchanged.

Also stop the serializer from appending to the stored failed recipe list in place, which grew the list on every upload without a download in between.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 21:52:32 +00:00
Cooldude2606 7987a5d925 Merge pull request #991 from bbassie/feature/clone-instance-save
Add save copying and start options when creating an instance
2026-09-09 20:03:27 +01:00
Cooldude2606 6671e3493e Merge pull request #994 from bbassie/fix/log-invalid-message-errors
Include schema validation errors in InvalidMessage stack
2026-09-09 15:29:03 +01:00
bbassieandClaude Fable 5.1 4730faea0c Cover the message fallback in Link error logging
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:15:46 +00:00
bbassieandClaude Fable 5.1 dc129b2fad Include schema validation errors in InvalidMessage stack
Generic error handlers such as FactorioServer.handle() and the event
handler catch in Link only log err.stack, so the ajv errors attached to
an InvalidMessage were lost unless the catch site checked for the class
explicitly. Appending the errors to the stack in the constructor makes
every stack-logging handler show which field failed validation without
special casing each one.

Fixes #986

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:57:05 +00:00
bbassieandClaude Opus 5 9b17fa2b93 Add host, server name and start options to the create instance modal
Accounts with core.instance.assign get a host selector, defaulting to
unassigned. Picking a host offers "Start after creation", which creates a
new save on first run when no save is copied, and together with a config
to copy it offers "Copy Save". Copying a config also offers a server
name, prefilled from the source, which overwrites factorio.settings.name
so the clone does not show up under the source name in the server
browser.

useInstanceConfig now clears the config while loading and drops errors,
so an account without core.instance.get_config just does not get the
server name field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 09:40:00 +00:00
Cooldude2606 abfc5ff8d5 Merge pull request #990 from bbassie/fix/inventory-sync-shortcut-toggles
Sync shortcut toggles and game view settings in inventory_sync
2026-09-08 00:33:35 +01:00
bbassieandClaude Fable 5.1 9aeb23e5da Sync roboport, movement bonus, and game view settings in inventory_sync
The built-in shortcuts are not toggleable in their prototypes and
set_shortcut_toggled only affects custom Lua shortcuts, so the state
behind them is synced instead: allow_dispatching_robots on the
character, inhibit_movement_bonus on the armor grid, and every field of
game_view_settings. map_view_settings is write-only and cannot be read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 23:16:33 +00:00
bbassieandClaude Fable 5.1 d9bce1e39e Sync every toggleable shortcut instead of a fixed list
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:10:43 +00:00
bbassieandClaude Fable 5.1 c997fd8ea7 Add save copying and start options when creating an instance
The create instance modal can now copy the active save of the instance
whose config is copied, optionally saving the running game first, and
start the new instance once the copy is done. The new instance is
assigned to the host of the source so the save can be transferred.

Saving a running game is a new InstanceSaveGameRequest handled by the
host through /server-save, which waits for the save to finish. It is
also exposed as clusterioctl instance save-game.

Closes #405

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:19:24 +00:00
bbassieandClaude Fable 5.1 75ac8bb491 Sync personal roboport and movement bonus toggles in inventory_sync
The toggle state of the personal roboport and equipment movement bonus
shortcuts lives on the player and was not part of the serialized player
data, so it reset when switching instances.

Closes #898

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:13:12 +00:00
Hornwitser e09e1c5f80 Merge pull request #983 from Hornwitser/configurable-static-url
Configurable static assets URL
2026-08-31 21:39:25 +02:00
Hornwitser 71f78c14a3 Add command for copying static assets to a folder
This is useful in case the static assets are hosted by a separate web
server like Nginx, or for testing that the static_url option works as
intended.
2026-08-31 20:14:13 +02:00
Hornwitser 7c0d7faa24 Add config to set URL for static assets
Add option in the controller config to set where static assets are
loaded from in order to support deployments where the static assets are
server via a CDN while the API is directly accessed.

This is useful because some CDNs put low limits on how large requests
bodies can be, which causes issues with uploading large mods and saves.
2026-08-31 19:17:31 +02:00
Cooldude2606 0f1fbb8fe5 Merge pull request #981 from bbassie/fix/static-root-web-root
Make staticRoot include the static/ prefix and use it for plugin bundles
2026-08-31 00:38:31 +03:00
Cooldude2606 1915250529 Merge pull request #979 from bbassie/feature/emmylua-lint
Add emmylua lint CI for module Lua
2026-08-31 00:25:06 +03:00
Cooldude2606 4d75f8d402 Add luarc to git ignore file 2026-08-30 22:15:56 +01:00
Cooldude2606 cc8f590bf4 Merge pull request #982 from Cooldude2606/ci/change-strategy
Update the test strategy
2026-08-30 23:55:49 +03:00
Cooldude2606 384a185a2e Update the test strategy
We now only perform one test on the oldest node version. In addition, timeout values were added and some other small tidy ups.
2026-08-30 21:50:47 +01:00
Cooldude2606 285a0018c9 Merge pull request #978 from bbassie/feature/version-compat-ci
Add CI check for controller and host version compatibility
2026-08-30 23:07:49 +03:00
bbassieandClaude Fable 5 930223019e Add serveWeb test for webRoot and staticRoot substitution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:54:25 +00:00
bbassieandClaude Fable 5 4c252a0763 Add tests for stripStaticPrefix and /api/plugins
The codecov/patch check flagged the new routes code as uncovered. The
unit tests never hit /api/plugins, so cover the static/ stripping and the
manifest error paths directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:48:54 +00:00
bbassieandClaude Fable 5 bd6bf480da Drop changelog edit, entries go in the PR description
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:35:35 +00:00
bbassieandClaude Fable 5 2f4e0c4418 Make staticRoot include the static/ prefix and use it for plugin bundles
staticRoot was set to the same value as webRoot, so the static/ prefix
ended up scattered across consumers: index.html relied on the manifest
path containing it, the export stores hardcoded `${staticRoot}static/`,
and bootstrap loaded plugin bundles from webRoot instead. Now staticRoot
is `${webRoot}static/` and the controller strips the static/ prefix from
bundle paths before handing them to the page and to /api/plugins.

Plugins that fetch assets with `${staticRoot}static/...` need to drop the
static/ part.

Fixes #980

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 09:50:08 +00:00
bbassieandClaude Fable 5 4caf585048 Add emmylua lua lint CI check
Same setup as established in ExpCluster, emmylua_check with factorio
typedefs generated by fmtk luals-addon. The moduleMap resolves
modules/clusterio and modules/<plugin> requires to their source under
packages/host and plugins, and the mod scaffolding under
packages/host/lua to bare sibling requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:25:24 +00:00
bbassieandClaude Fable 5 a25f8c8611 Fix lua lint warnings reported by emmylua
The largest source was compat.lua returning the setmetatable call
directly, which made emmylua infer the module type from the metatable
rather than the LibCompat class, hiding every field from consumers.

The remaining fixes are nil guards on gui element and character
accesses, type annotations for research_sync's script data and
player_auth's rcon arguments, casts where the api's read/write union
types are wider than the values at runtime, and scoped diagnostic
disables for the vendored json.lua and the version fallback code that
touches apis removed from the current typedefs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:25:24 +00:00
bbassieandClaude Fable 5 6cf3ab7007 Compare link message serialization in version compat check
The smoke test only proves the handshake and a few core requests still
work, while compat most often breaks because the serialization of a
link message changed. Dump the registered link messages of the local
and published lib in separate processes (the lib forbids loading two
copies in one) and diff their src, dst and json schemas. A changed
message fails the test, added and removed messages only produce a
warning annotation since breaking compat is unavoidable there.

Requested by Cooldude2606 in review of #978.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 12:29:26 +00:00
bbassieandClaude Fable 5 687c6c15f5 Add CI check for controller and host version compatibility
Runs a smoke test in both directions between the current code and the
latest release published to npm: the host connects to the controller and
gets an instance assigned. Testing only against the latest release relies
on each release being compatible with the previous one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:55:07 +00:00
Cooldude2606andClaude Fable 5 59cd193b65 Support enemy_expansion.build_base_unit_dispatch_cooldown in exchange strings (#977)
* Support enemy_expansion.build_base_unit_dispatch_cooldown in exchange strings

Factorio 2.1.13 added build_base_unit_dispatch_cooldown as the last
field of enemy_expansion in the map exchange string, and rejects map
settings without it. Track the full version in the reader state and
read the field for 2.1.13 and later. Adds a fixture string generated
with 2.1.14 and uses it in the load-scenario integration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Replace version flags with versionAtLeast in exchange string reader

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:24:58 +01:00
BastiaanandClaude Fable 5 7bd408b45e Restore pagination on the instance save list (#973)
useTableQueryState returns pagination=false unless a pagination config
is passed, and SavesList did not pass one after #930, so the saves table
rendered all saves on one page.

Fixes #967

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:22:57 +01:00
Cooldude2606andClaude Fable 5 00ce7cdca6 Filter dependency resolution by the mod pack's Factorio version (#974)
* Filter dependency resolution by the mod pack's Factorio version

The dependency resolver picked the newest release satisfying the version
range regardless of which Factorio version it targeted, so a 2.0 mod pack
could end up with 2.1 dependencies. Only consider portal releases and
local mods whose factorio_version matches the mod pack.

Fixes #965

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move dependency resolver tests to test/messages/mod.test.js

Also update the existing local mod fixtures to carry a factorio_version
and drop enable-all-feature-flags from the live expectations: the 2.0
releases of pyalternativeenergy only list it as optional, it was only
pulled in before because 2.1 releases were being selected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:21:21 +01:00
BastiaanandClaude Fable 5 1c5210c210 Handle create_character failures in inventory sync (#975)
ensure_character asserted on player.create_character() returning false
without any context (#912). The most likely trigger is the player being
physically inside a space platform hub (or otherwise stuck in remote view)
on the destination server: the character in the hub is destroyed while
downloading and the god controller ends up on the platform surface where
a character can not be created.

- Explicitly exit remote view and move the god controller off platform
  surfaces before create_character; restore_position re-enters the platform
- Include controller/surface/connection state in the error message
- On deserialization failure treat the player as a temporary inventory
  (sync = false) so the incomplete state is not uploaded over synced data
- Release the acquisition on leave when nothing is uploaded so the player
  is not left "busy" on other instances

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:12:30 +01:00
Cooldude2606andClaude Fable 5 b5a0d43c96 Add NO_EXTERNAL_TEST flag to skip tests using external APIs (#976)
* Add NO_EXTERNAL_TEST flag to skip tests using external APIs

Adds an externalTest(this) helper alongside slowTest that skips the test
when NO_EXTERNAL_TEST is set and raises the timeout to 60s, plus a
no-external-test script. Marks the tests hitting factorio.com, the mod
portal and GitHub with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Disable user verification in start-all and auto_start tests

Factorio contacts auth.factorio.com on startup when
require_user_verification is enabled, which made these tests fail
offline. Match the other integration tests by turning it off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:07:08 +01:00
BastiaanandClaude Fable 5 c17c582818 Download Factorio in host tests to temp/test instead of test/file (#972)
* Ignore Factorio download directory created by host tests

The live-API download test in test/host/server.js writes a full headless
Factorio install (~140 MB) to test/file/factorioDownload and does not
remove it, which then shows up as untracked files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Download Factorio in host tests to temp/test instead of test/file

The live-API download test wrote a full headless install (~140 MB) into
test/file/factorioDownload, which is tracked test fixture territory and
showed up as untracked files. temp/ is already gitignored and is where
the other tests put generated data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:00:55 +01:00
bbassieandClaude Fable 5 d3c618a7ad Replace version flags with versionAtLeast in exchange string reader
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 22:57:36 +00:00
bbassieandClaude Fable 5 021ee1725b Support enemy_expansion.build_base_unit_dispatch_cooldown in exchange strings
Factorio 2.1.13 added build_base_unit_dispatch_cooldown as the last
field of enemy_expansion in the map exchange string, and rejects map
settings without it. Track the full version in the reader state and
read the field for 2.1.13 and later. Adds a fixture string generated
with 2.1.14 and uses it in the load-scenario integration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 22:21:28 +00:00
bbassieandClaude Fable 5 ffc79ea0e7 Disable user verification in start-all and auto_start tests
Factorio contacts auth.factorio.com on startup when
require_user_verification is enabled, which made these tests fail
offline. Match the other integration tests by turning it off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 20:26:30 +00:00
bbassieandClaude Fable 5 0cfa048d42 Add NO_EXTERNAL_TEST flag to skip tests using external APIs
Adds an externalTest(this) helper alongside slowTest that skips the test
when NO_EXTERNAL_TEST is set and raises the timeout to 60s, plus a
no-external-test script. Marks the tests hitting factorio.com, the mod
portal and GitHub with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:55:18 +00:00
bbassieandClaude Fable 5 5e633ac2f1 Move dependency resolver tests to test/messages/mod.test.js
Also update the existing local mod fixtures to carry a factorio_version
and drop enable-all-feature-flags from the live expectations: the 2.0
releases of pyalternativeenergy only list it as optional, it was only
pulled in before because 2.1 releases were being selected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:54:19 +00:00
bbassieandClaude Fable 5 5e6b06f009 Filter dependency resolution by the mod pack's Factorio version
The dependency resolver picked the newest release satisfying the version
range regardless of which Factorio version it targeted, so a 2.0 mod pack
could end up with 2.1 dependencies. Only consider portal releases and
local mods whose factorio_version matches the mod pack.

Fixes #965

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:30:32 +00:00
Floze 0be4fd77e5 Allow disabling player online autosaves (#971) 2026-08-14 10:24:15 +01:00
Cooldude2606 03f2616f30 Merge pull request #955 from bbassie/feature/console-log-filter-labels
Fix the instance console hiding Factorio diagnostics
2026-08-13 00:20:44 +01:00
Cooldude2606 244d637fac Reduce tooltip length 2026-08-12 22:25:31 +01:00
bbassieandClaude Opus 5 71db8e57f5 Label Factorio output in the console with its log level
The Web UI console rendered server level entries as bare parsed Factorio
output, while every other level was prefixed with its name. Nothing in a
line distinguished what the game wrote from what Clusterio wrote about it,
which matters more now that both modes of the filter carry a mix of the
two.

Emit the [server] prefix for those entries as well, matching what
TerminalFormat already does for the host and controller logs, and style the
level the same bold the terminal colourises it with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:58:30 +00:00
bbassieandClaude Opus 4.8 16092ec967 Make the instance console log filter discoverable
The filter was a Switch showing only the label of the mode it was already
in, so with the default of chat mode it read as a button labelled "Chat"
with no hint that a Log mode existed or that anything was being hidden.

Swap it for a Segmented control listing both Chat and Log, so the mode that
is not active is always visible, and add a tooltip describing what each one
covers.

Fixes #853

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:58:21 +00:00
bbassieandClaude Opus 4.8 42dc83df80 Stop the instance console filter swallowing diagnostics
The console's chat filter kept only Factorio output parsed as an action,
which dropped the server's replies to those actions. Running a console
command with a typo in it echoed the command and then appeared to print
nothing, because "Cannot execute command. Error: ..." is a plain message
rather than an [ACTION] line. Factorio's engine level errors were dropped
the same way, so a failed multiplayer connection left no trace either.

Filter by where a line came from instead. Factorio interleaves two streams
on stdout: the date stamped console log, which carries what was done to the
server and the replies to it, and the seconds stamped engine log, which is
verbose diagnostic output. Keep the console log whole and take only
warnings and errors from the engine log.

Apply the same rule to Clusterio's own entries, which were previously kept
in full and drowned the Factorio output they were mixed with. Keep those at
warn or worse and drop the routine bookkeeping. Replayed over a development
cluster's history this drops 415 informational lines and surfaces engine
errors that were never visible in either mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:58:19 +00:00
Cooldude2606 3c9229087b Merge pull request #962 from bbassie/feature/plugin-system-tests
Add integration tests for the plugin system
2026-08-11 15:25:17 +01:00
Cooldude2606 15a8215a4f Merge pull request #956 from bbassie/feature/plugin-change-refresh
Reload the web interface when the controller's plugins change
2026-08-10 16:57:30 +01:00
Cooldude2606 499d219933 Remove overly verbose comments 2026-08-10 16:45:56 +01:00
bbassieandClaude Opus 4.8 1e4b586fa3 Distinguish a plugin that was never loaded from one that failed
The plugin list reported "Error loading module" for anything without a
loaded package, which includes plugins the controller has picked up since
the page was opened. Nothing had been tried for those, so reporting a
failure was both wrong and unhelpful: the fix is to reload, not to
investigate.

Report those as needing a reload instead. Plugins the controller cannot
serve at all are excluded during startup before they reach the loaded
plugin map, so those are checked for first and still count as an error.

Fixes #729

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 10:01:38 +00:00
bbassie e3c18db7b8 Reload the web interface when the controller's plugins change
The web interface loads plugin bundles, registers their messages and
adds their config fields once in bootstrap, and has no way to undo any
of it.  A controller restarted to pick up a plugin being installed,
enabled or disabled comes back running a different set, and a controller
started in recovery mode runs none of them while still accepting control
connections, so open pages carry on sending messages it no longer
handles.

The controller already states the plugins it is running in the handshake
it sends on every connection, which is what hosts track as serverPlugins
and use to decide what to forward and which instance plugins to load.
Compare that against the set the page loaded and stop rendering on a
mismatch, asking to be reloaded instead.

The comparison is against loaded rather than enabled because loaded is
what the handshake reports and what the controller is actually running;
enabled also takes load_plugin into account, which only applies from the
next restart and would otherwise latch a permanent mismatch.

Closes #804
2026-08-10 10:01:35 +00:00
Columbus LabsandColumbusLabs aa59cd1992 fix: filter server-specific chat tags (#970)
Co-authored-by: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com>
2026-08-09 21:35:13 +01:00
Cooldude2606 c0138c3fe0 Merge pull request #961 from bbassie/feature/instance-host-broadcast
Deliver broadcasts to all hosts, not just the local one
2026-08-08 23:51:48 +01:00
Cooldude2606 a186bde2a9 Fix CI not performing coverage 2026-08-08 23:47:24 +01:00
Cooldude2606 e9450db83b Move test location 2026-08-08 23:45:03 +01:00
Cooldude2606 bdf5e3011f Merge pull request #959 from bbassie/feature/forwarded-request-cleanup
Forget a forwarded request that could not be sent
2026-08-08 23:34:19 +01:00
Cooldude2606 48c07f9ab3 Make explanation comment less verbose 2026-08-08 23:26:40 +01:00
Cooldude2606 f6240d0911 Merge pull request #947 from solarcloud7/lint/link-method-binding
Document Link method binding requirements
2026-07-31 18:47:27 +01:00
Cooldude2606 6e74db1f9e Merge pull request #963 from lntutor/feat/transitive-plugin-discovery-953
Discover transitive NPM plugins
2026-07-31 18:44:55 +01:00
Cooldude2606 bfbd41d64b Cleaner path resolution and tests 2026-07-31 18:33:19 +01:00
Ryan Walker d9e1829fcf Human prose.. :D 2026-07-30 20:46:21 -04:00
Loi Nguyen dfb6037797 fix(lib): address transitive plugin review 2026-07-31 00:01:16 +07:00
Cooldude2606 02e50211f4 Merge pull request #957 from bbassie/feature/download-file-cleanup
Discard the temporary file when a download fails
2026-07-29 23:34:06 +01:00
Cooldude2606 3a689b379a Merge branch 'master' into feature/download-file-cleanup 2026-07-29 23:13:29 +01:00
Cooldude2606 e4c2ccd58f Merge pull request #954 from bbassie/feature/redact-mod-portal-token
Redact credentials from URLs in download errors
2026-07-29 23:02:12 +01:00
Cooldude2606 417ee87422 Maintain repeated credential parameters 2026-07-29 22:56:19 +01:00
Cooldude2606 d7eb647c09 Merge pull request #952 from lntutor/feat/mod-info-recommendations-949
Show informational mod recommendations
2026-07-26 12:34:09 +01:00
Loi NguyenandLoi Nguyen 08651b5917 fix: prevent restart downgrades (#951)
Co-authored-by: Loi Nguyen <1948922+lntutor@users.noreply.github.com>
2026-07-26 12:20:22 +01:00
Loi NguyenandClaude Opus 4.8 74bd19c063 Remove redundant recommended-dependency duplicate guard
Address review feedback: modsByName is built from annotatedMods so the
advisories array always exists, and each source mod is iterated once with
no duplicate dependencies, so the existence check can never fail. Push the
recommended_dependency advisory directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8b9dEaJtpvydzz4ttM7JH
2026-07-26 17:26:17 +07:00
Loi Nguyen dfe52df7bb Show informational mod recommendations 2026-07-26 17:05:44 +07:00
Loi Nguyen 40a6142e3e feat(lib): discover transitive npm plugins 2026-07-26 13:23:20 +07:00
bbassieandClaude Opus 4.8 b415d16408 Add integration tests for the plugin system
Nothing covered whether plugins are loaded at all, or whether the messages
they declare are routed. The integration tests only loaded the first party
plugins, so what coverage existed came incidentally from testing those.

Add a plugin under test/file which exists purely to be loaded by the tests.
It is plain JavaScript rather than TypeScript so that it needs no build
step, which the plugin loader is happy with as it requires the entry point
and reads the version from package.json like any other plugin.

Covered so far: the plugin loads on the controller and on the host, its
title and version are reported, the command it adds is available in
clusterioctl, a request from control reaches the controller, a request from
control reaches a host, and an event sent with broadcastEventToHosts
arrives at the hosts.

Left for a follow up, as all three need a running instance: a request to an
instance, a Lua module reached over rcon, and log statements being tagged
with the plugin and host or instance id.

Part of #342, and a step towards #721 as core no longer needs a first party
plugin to have the plugin system under test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 18:13:28 +00:00
bbassieandClaude Opus 4.8 09c667252c Deliver broadcasts to all hosts, not just the local one
An event broadcast from an instance to all hosts only reached the host
running that instance. The host side of an instance connection has the
host's own address as its source, so a broadcast aimed at hosts counts as
addressed to it, and a link that a message is addressed to handles it and
stops. The event was delivered locally and never routed onwards.

A broadcast is addressed to every link of the type it targets, so arriving
at one of them does not mean it has arrived everywhere. Keep routing such a
message after handling it locally. Only links that route are affected, as
instances and controls have no router and would otherwise throw on every
broadcast they receive.

The only links whose source type matches a broadcast target and which route
are the host and its instance connections, both matching a broadcast to
hosts, so add the missing case for that to the host router. It forwards to
the controller, which already passes broadcasts on to every other host, and
stops when the message came from the controller to begin with.

Drop the exclusion this bug had in the routing test matrix, which now
covers it along with the plugin filtering variants.

Fixes #575

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 17:39:25 +00:00
Cooldude2606 9ff090c30b Merge pull request #958 from bbassie/feature/subscription-event-address
Stop addressing subscription events to a request id
2026-07-25 15:28:55 +01:00
Cooldude2606 9fe1819ea5 Fix tests 2026-07-25 15:23:34 +01:00
Cooldude2606 cc92e851ad Rename subscriber.dst to subscriber.address 2026-07-25 15:10:57 +01:00
Cooldude2606 40f1cf6332 Merge pull request #960 from bbassie/feature/silence-dep0190
Silence the deprecation warning on install
2026-07-25 14:51:59 +01:00
Cooldude2606 870e1f2125 Remove unneeded comments 2026-07-25 14:51:14 +01:00
bbassieandClaude Opus 4.8 70305b7937 Silence the deprecation warning on install
execFile was called with shell: true and an array of arguments, which Node
deprecates in DEP0190 because it concatenates the arguments by space
without escaping anything, giving the impression they have been made safe.
The warning was printed to the console on every install.

The arguments here are already escaped for the shell by escapeArg, so join
them into the command instead of handing them to execFile to concatenate.
Nothing about what ends up being run changes.

Point the escaping test at the same form so it covers what the installer
actually does, and stops emitting the warning itself. Its several hundred
round trip cases still pass, which is what confirms the escaping is
unaffected.

Fixes #851

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:02:37 +00:00
bbassieandClaude Opus 4.8 3512042cf3 Forget a forwarded request that could not be sent
forwardRequest records a request so that its response can be sent back the
way it came, but recorded it before handing the message to the connector.
When the connector threw, because the session to the next hop was gone, the
record stayed behind for a request that had never left.

Routers answer the origin themselves when forwarding throws, so the request
was already accounted for. The leftover record then had it answered a
second time when the link cleared its pending requests on disconnect,
producing a duplicate response and "Received response N without a pending
request" at the far end.

Remove the record when the forward throws. Doing it here rather than in the
routers covers both the host, which catches and responds, and the
controller router, which does not.

Fixes #650

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:58:37 +00:00
bbassieandClaude Opus 4.8 4a9c241eee Stop addressing subscription events to a request id
A subscribe arrives as a request, so the source address the subscription
controller handles it with carries the request id used to route the
response back to the caller. That address was stored verbatim as the
destination for the subscription, so every event broadcast to it, and the
replay event sent when the subscription is created, went out addressed to
a request that had long since been answered.

A request id identifies one request in flight and has no meaning on an
event. The receiving link keys response routing off it, so events carrying
one are put through paths meant for response traffic.

Strip the request id when taking the subscription's destination from the
request that created it. Add Address.withoutRequestId for that, as the
same address is used in three places here.

The subscription map is keyed on addressIndex, which already excludes the
request id, so subscriptions were never duplicated by this and only the
address events are sent to was wrong.

Fixes #847

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:49:04 +00:00
bbassieandClaude Opus 4.8 3883c1932c Discard the temporary file when a download fails
downloadFile opens a write stream before fetching, but only closed the
stream and removed the file it was writing to when the server answered
with a bad status. A request that never reached the server, or a
connection dropped part way through the body, left both the temporary file
and an open file handle behind.

Mods are downloaded through this in a Promise.all over every mod in a pack,
so one bad moment on the network scatters temporary files across the mods
directory. They are not harmless: createTempWriteStream picks the first
free name, so each leftover file permanently shifts the name used for
later downloads of the same mod, as the existing "should not overwrite
existing temp files" test describes.

Move the fetch and the body write into a try block and discard the stream
and its file on the way out. Destroy rather than end the stream, as the
content is being thrown away, and skip both if the stream has already
closed so that a failure after the body was written does not wait forever
for a close event that has already fired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:26:18 +00:00
bbassieandClaude Opus 4.8 feb0e46aab Redact credentials from URLs in download errors
The mod portal takes the Factorio account token as a query parameter on the
download URL. When a download failed, downloadFile put the whole URL into the
error message, which ModStore then logged verbatim, printing the token into the
log at error level on every failed mod download.

Add a redactUrl helper that replaces the value of credential-carrying query
parameters with REDACTED, and use it when downloadFile builds its error.
Parameter names and the rest of the URL are kept so the message stays useful
for diagnosing which request failed; username is deliberately left intact as it
is not a secret and identifies the account in use.

Fixes #795

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:38:09 +00:00
Cooldude2606 5e0ab3423f Chore: More consistent package files
This commit does not contain any material changes.
2026-07-19 02:29:29 +01:00
Cooldude2606 e4a38e803c Add repository to all packages 2026-07-19 01:53:53 +01:00
Cooldude2606 95cd830c01 2.0.0-alpha.27 2026-07-19 00:10:11 +01:00
Cooldude2606 ddc03eaf30 Bump engines requirement 2026-07-18 23:50:33 +01:00
Cooldude2606 b5d8cde93f Fix test for player auth controller plugin 2026-07-18 23:48:32 +01:00
Cooldude2606 4e8a833d62 Fix write permission in publish ci 2026-07-18 23:32:48 +01:00
Cooldude2606 3add08b100 Update naming within exchange string lib 2026-07-18 23:32:15 +01:00
Daniel f2b28968a0 Merge pull request #943 from Cooldude2606/ci/oidc
OIDC Publish, Node version bump, and 2.1 tests working
2026-07-19 00:20:31 +02:00
Daniel 599cf3ed22 Merge pull request #915 from Cooldude2606/feature/player-auth-display-version
Add version and connect button to player auth
2026-07-18 23:29:14 +02:00
Cooldude2606 ff28357d7a Remove hard coded versions 2026-07-18 22:12:15 +01:00
Cooldude2606 8b4c8ad337 New exchange string format for enemy expansion settings 2026-07-18 22:12:15 +01:00
Cooldude2606 07b030d045 Add 2.1 version to clusterio_lib 2026-07-18 22:12:14 +01:00
Cooldude2606 7f799c8490 Bump node version 2026-07-18 22:12:14 +01:00
Cooldude2606 91a5108b85 Add OIDC permissions to publish workflow 2026-07-18 22:12:13 +01:00
Cooldude2606 90c8188952 Address feedback
* Fix typo in toJSON
* Handle the case of hostId being 0
* Hide address when host address is empty string
2026-07-18 22:07:54 +01:00
Cooldude2606 198ea76674 Add support for recommended mod dependencies (#946) 2026-07-18 21:57:50 +01:00
Daniel 5758cd94b6 Merge pull request #945 from Cooldude2606/fix/duplicate-instances
Setting filepath on config makes it dirty
2026-07-18 21:14:56 +02:00
solarcloud7 0e8fe54ae3 Mention .bind for methods passed as values 2026-07-12 01:29:24 -04:00
solarcloud7 5e1455c3d6 Document Link method binding requirements 2026-07-11 19:34:43 -04:00
Cooldude2606 eab1a42e07 Make showing the connect address a config option 2026-07-11 23:04:42 +01:00
Cooldude2606 fdfd77df9a Add modal containing details of how to connect to server 2026-07-11 22:46:53 +01:00
Cooldude2606 58f2ad9810 Merge pull request #930 from bbassie/feature/table-url-state
Persist table sort/filter state in the URL
2026-07-10 23:13:53 +01:00
Cooldude2606 3cc99a1ea6 Fix commit filter logic for empty search strings 2026-07-10 23:10:54 +01:00
Cooldude2606 15781bb038 Setting filepath on config makes it dirty 2026-07-10 22:00:42 +01:00
Cooldude2606 9ec12ac7d7 Merge pull request #944 from Cooldude2606/fix/quick-bar-slots
Add string encoding/decoding and update quick bar support
2026-07-10 20:59:37 +01:00
Cooldude2606 2559c0478c Use item name only for better size usage 2026-07-10 20:59:06 +01:00
Cooldude2606 07fcdb0d4e Merge pull request #942 from Cooldude2606/refactor/sub-filters-again
Make SubscriberFilter extend Set and expose errors in EventSubscriber
2026-07-08 08:30:37 +01:00
bbassieandClaude Opus 4.8 40407e52d6 Fix clearing a live-but-uncommitted search re-committing the old value
commitFilter now reads the live overrides from a ref that setFilter updates
synchronously, so committing within the same event that changed a filter no
longer sees a stale render closure. antd's Input.Search fires onSearch (before
onChange) when the clear button is clicked, so onSearch now commits the cleared
state instead of closing the dropdown, which had re-committed the still-live
typed value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:02:25 +00:00
bbassieandClaude Opus 4.8 2898fdf5d3 Write the URL immediately when a search filter is cleared
The search box clear (X) button cleared the filter live but only wrote the URL
when the dropdown closed. commitFilter now accepts an explicit value so the
clear handlers can commit right away (avoiding the stale-state race of reading
the live override in the same event), for both useColumnSearch and the Users
filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:37:32 +00:00
bbassieandClaude Opus 4.8 bb4c82ea5a Make all table search live and only write the URL on close
Add a live filter override to useTableQueryState: setFilter updates the filter
immediately (so every table's search filters as you type) without touching the
URL, and commitFilter writes it to the URL when the filter dropdown closes.
useColumnSearch and useUserFilter now filter live via setFilter and commit on
close, so the Users table no longer rewrites the URL on every keystroke and the
other tables gain live search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:14:32 +00:00
bbassieandClaude Opus 4.8 1f2730a64f Add name-column search to the Mods and Mod Packs tables
These were the only tables without the column search filter; wire useColumnSearch
into both name columns with URL-persisted filteredValue, matching the other tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:58:16 +00:00
Cooldude2606 43238feada Merge pull request #941 from bbassie/feature/lenient-mod-version-parsing
Parse mod versions leniently like the game does
2026-07-02 00:50:06 +01:00
Cooldude2606 573274875c Rename to SourceVersion 2026-07-02 00:27:05 +01:00
Cooldude2606 2e27c4f499 Maintain strict tagging of GameVersion 2026-07-02 00:08:09 +01:00
bbassieandClaude Opus 4.8 8029ad3b2d Resolve GameVersion type as a plain-string supertype
Cooldude's GameVersion brand was disjoint from the clean version types in
both directions, forcing safeVersion/safeFactorioVersion companions and
breaking widening from FullVersion at file-name call sites.

Make GameVersion a plain string alias: the raw, un-normalised version as the
game reads it. The stricter template-literal types (FullVersion,
MajorMinorVersion, PartialVersion) now widen into it for free, while a
GameVersion still can't be used where a normalised version is required
without going through normaliseGameVersion/integerGameVersion. Runtime
validation stays in GameVersionSchema.

- Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so
  it matches the mod's file name), integerVersion normalises via the new
  integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion
  since it is never part of a file name.
- ModRecord.version becomes a GameVersion so mod packs round-trip the exact
  file-name version.
- ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw
  GameVersion and normalise it; the equality bound stays strictly validated.
- Migration only pads clean partial versions; raw versions are kept verbatim.
- Cover the leading-zeros case: version kept verbatim in the file name while
  comparing equal to its normalised form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:54:06 +00:00
Cooldude2606 eca5cdcd9e Merge pull request #939 from bbassie/feature/resizable-console
Make the Web UI console resizable
2026-07-01 00:25:28 +01:00
Cooldude2606 523293d4d9 Manual adjustments to GameVersion and ReleaseChannel types
Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type.  The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided.
2026-06-30 23:39:08 +01:00
bbassieandClaude Opus 4.8 5d20f8fc84 Revert strict ModInfo.jsonSchema and cover the factorio_version error
The strict major.minor jsonSchema did not improve any consuming type (the
factorioVersion field is already typed) and only complicated the fromJSON
signature, so revert it (and drop the now-unused MajorMinorVersionSchema).
Add a test for the unparseable factorio_version branch in fromJSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:20:37 +00:00
bbassie e27ad5fdf9 Merge remote-tracking branch 'upstream/master' into feature/lenient-mod-version-parsing
# Conflicts:
#	packages/lib/src/data/version.ts
2026-06-29 22:57:48 +00:00
bbassieandClaude Opus 4.8 5b1f7d796d Make ModInfo.jsonSchema strict about the major.minor factorio_version
The stored ModInfo always normalises factorio_version to a major.minor (see
fromJSON), so the serialised schema can use the strict MajorMinorVersionSchema
while infoJsonSchema keeps the lenient GameVersionSchema for raw info.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:49:59 +00:00
bbassie 0874c26208 Merge remote-tracking branch 'upstream/master' into feature/table-url-state
# Conflicts:
#	packages/web_ui/src/components/HostsPage.tsx
#	packages/web_ui/src/components/InstanceList.tsx
#	packages/web_ui/src/components/ModsPage.tsx
#	packages/web_ui/src/components/PluginsPage.tsx
#	packages/web_ui/src/components/RolesPage.tsx
#	packages/web_ui/src/components/UserViewPage.tsx
#	packages/web_ui/src/components/UsersTable.tsx
#	packages/web_ui/src/index.ts
2026-06-29 22:37:03 +00:00
bbassieandClaude Opus 4.8 1e7b8f08cf Address review: tidy pagination handling and comments
Let useTableQueryState accept a full TablePaginationConfig (only defaultPageSize
is read) so UsersTable passes its pagination prop straight through instead of
extracting and casting it. Drop the redundant pagination: false from the tables
that just want it disabled (that is already the default). Clarify the comment on
the Last Seen online-only default to mention the onlyOnline prop, and explain why
onChange only uses the first sorter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:26:39 +00:00
bbassieandClaude Opus 4.8 7c2793752d Add MajorMinorVersion type and dedupe the game version regex
Address review feedback: introduce a MajorMinorVersion type with
isMajorMinorVersion, rename majorMinorVersion to normaliseMajorMinorVersion,
and type ModInfo.factorioVersion as MajorMinorVersion so already-reduced
values are not re-reduced (ModPackViewPage uses mod.info.factorioVersion
directly). Reuse the single gameVersionRegExp in both GameVersionSchema and
normaliseGameVersion instead of duplicating the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:12:36 +00:00
bbassieandClaude Opus 4.8 5cde390c24 Use a spacer element for console scroll headroom
Replace the padding-top scroll headroom with a dedicated .console-spacer
element so the pin-to-bottom keeps working without padding inflating the
measured height, and trim the style.css comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:01:06 +00:00
Cooldude2606 46ebb13fda Update quick bar to support 2.1 and quality 2026-06-29 02:17:03 +01:00
Cooldude2606 4775a5fd06 Add encode_string and decode_string to compat 2026-06-29 01:35:05 +01:00
Cooldude2606 7e89e7fb6f Fix assert in test case 2026-06-26 22:15:28 +01:00
Cooldude2606 95ea073b77 Expose error on event subscriber snapshot 2026-06-26 22:04:06 +01:00
Cooldude2606 e135d37878 Change SubscriptionFilters to extend Set 2026-06-26 21:41:48 +01:00
bbassieandClaude Opus 4.8 2dedb44372 Fix being unable to shrink the console after enlarging it
The resize observer measured offsetHeight, which on the content-box console
includes the pin-to-bottom padding-top. Feeding that back into the height
(and thus the padding) meant the padding inflated the measured height, so
the console could be enlarged but never dragged smaller again.

Make the console border-box so its height already includes the padding and
offsetHeight matches the dragged height, breaking the feedback. The default
height is bumped to 600 so the rendered size stays the same as the previous
content-box height + padding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:38:35 +00:00
bbassieandClaude Opus 4.8 97a6c32838 Parse mod versions leniently like the game does
The mod portal "ApiVersion" enum was validated strictly when loading a
mod's info.json, which rejected versions the game itself accepts. Versions
are now normalised the same lenient way the game reads them: leading
whitespace is allowed, the major and minor are required, the patch is
optional, any trailing content is ignored, and numbers are read as integers.

This applies to both a mod's version and its factorio_version, validated by
a regex schema rather than the fixed enum. The enum is no longer used to
validate versions at all - the mod portal is the source of truth, so its
error is now surfaced when a version is unsupported. The known-version list
kept for the web interface fallback gains 2.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:24:43 +00:00
BastiaanandClaude Opus 4.8 a56fcbd785 Fix antd deprecation and TreeSelect duplicate-value warnings (#940)
Rename the deprecated Modal `destroyOnClose` prop to `destroyOnHidden`
(antd 5.25+) across the web UI.

Skip a version-tree child equal to its major.minor group key in
InputVersion, which triggered "Same value exist in the tree" warnings when
the version list contained bare major.minor entries (the ApiVersions
fallback shown before the version fetch resolves).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:21:52 +01:00
Cooldude2606 e16603bbd0 Merge pull request #937 from bbassie/feature/instance-version-channels
Add release channels (stable/experimental) as instance version targets
2026-06-26 17:00:44 +01:00
bbassieandClaude Opus 4.8 dfe132699e Add integration test for starting an instance with a release channel
Configures an instance with the "experimental" channel and starts it,
exercising channel resolution end to end across the controller and host.
The resolved version may or may not be installed depending on the host's
Factorio install, so the test asserts the instance reaches a terminal state
rather than a specific outcome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:38:29 +00:00
bbassieandClaude Opus 4.8 f793ec33b3 Add tests for release channel resolution and caching
Cover fetchLatestReleases and resolveReleaseChannel, the controller's
latest-releases cache with its on-disk offline fallback, the
LatestReleasesRequest round-trip, and FactorioServer.setTargetVersion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:10:06 +00:00
Cooldude2606 ce8bddad8f Merge pull request #931 from bbassie/feature/open-in-new-tab
Make navigation open in new tabs (real links)
2026-06-26 15:47:33 +01:00
bbassieandClaude Opus 4.8 3fe0c85055 Make the Web UI console resizable
The instance console was locked to a fixed 300px height. It can now be
drag-resized vertically (native resize handle), and the chosen height is
persisted with useLocalStorage so it is restored on the next visit.

Both the height and the scroll-anchor padding are driven by a
--console-height CSS variable, so the pin-to-bottom behaviour keeps working
at any size.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:32 +00:00
bbassieandClaude Opus 4.8 9da48d5387 Add release channels as instance version targets
An instance's factorio.version can now target a release channel such as
"stable" or "experimental" (any key from factorio.com/api/latest-releases)
in addition to "latest" and specific X.Y / X.Y.Z versions.

The controller proxies the latest-releases API through a ValueCache and
persists the last successful response to latest-releases.json, serving that
copy when the API is unreachable. On instance start the host resolves a
channel to the concrete version it points to and feeds it into the existing
checkForUpdates/findVersion path, so a channel auto-installs and runs like a
pinned version. The version selector in the web UI lists the available
channels alongside "latest".

Also clarifies the host log/error when a newer version cannot be downloaded
automatically because the host uses a direct (single-version) Factorio
install rather than a versioned directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:19:34 +00:00
bbassieandClaude Opus 4.8 2bd9bc6138 Fill table link cells with a block link instead of an overlay
Replace the absolutely-positioned a::after overlay with display:block on
the link. The overlay covered sibling cell content (the restart-required
icon), swallowing its hover so the tooltip never triggered. A block link
fills the cell width without overlapping siblings.

Also skip rendering the assigned-host link when an instance has no
assigned host, avoiding a dead /hosts/undefined/view link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:24:34 +00:00
Daniel a274fe74e1 Merge pull request #936 from Cooldude2606/fix/925
Add "recycler" as builtin mod for 2.1
2026-06-26 10:25:41 +02:00
Daniel ea5c35198f Merge pull request #924 from bbassie/feature/sidebar-layout
Rework site layout into a fixed sidebar
2026-06-26 10:06:23 +02:00
Cooldude2606 0f6f3bbb8a Add "recycler" as builtin mod for 2.1 2026-06-26 02:20:32 +01:00
bbassieandClaude Opus 4.8 448eb9223d Make table link cells fill the cell and fix focus colour
Stretch the navigation link over the whole table cell (a position:relative
cell with an ::after overlay) so the entire name cell - and the Instances
Assigned Host cell - is clickable and right-click "open in new tab"-able,
not just the text. Also force color:inherit on the sidebar and account links
so the focus left behind by a middle-click no longer shows them blue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:55:29 +00:00
bbassieandClaude Opus 4.8 cc0be69019 Enlarge the sidebar logo to 56px
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:41:25 +00:00
Cooldude2606andClaude Opus 4.8 9c627f4c16 Add cluster overview landing page (#933)
* Add cluster overview landing page

Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add saves card and tidy the overview grid

Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Change order of cards and fix permission check

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-06-25 16:27:55 +01:00
Cooldude2606 118154a43a Change order of cards and fix permission check 2026-06-25 16:20:48 +01:00
BastiaanandClaude Opus 4.8 b29f4db52c Show the public address on the instance details page (#935)
The instance list shows the public connect address but the instance detail
page did not. Add a Public address row (host:port) with a copy button, and
lay the details out 2x2 so the extra row stays balanced.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:15:18 +01:00
Cooldude2606 5efa257a36 Merge pull request #932 from bbassie/feature/remember-console-toggle
Remember the instance console chat/log toggle
2026-06-25 16:14:44 +01:00
bbassieandClaude Opus 4.8 09ec6d6ec5 Add saves card and tidy the overview grid
Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:40:22 +00:00
bbassieandClaude Opus 4.8 8cacdc5112 Use inline sort/filter getters instead of mapping columns
Replace the satisfies-ColumnsType + .map(applyColumn) approach with inline tableState.sortOrder()/filteredValue() getters set per column, so the column arrays stay plain literals (no satisfies, no constraints; UsersTable stays any[]). Move useColumnSearch into util/ next to useTableQueryState.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:04 +00:00
bbassieandClaude Opus 4.8 d4394f1190 Link the sidebar logo to the overview page
Make the Clusterio logo in the top bar a link to / so it lands on the overview/dashboard page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:03 +00:00
bbassieandClaude Opus 4.8 6feb9c2762 Add useLocalStorage hook for the console toggle
Move the instance console chat/log toggle persistence into a generic useLocalStorage hook that defers the write so it never blocks the toggle update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:31:49 +00:00
bbassieandClaude Opus 4.8 79c6bd1ac4 Add cluster overview landing page
Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 01:21:17 +00:00
bbassieandClaude Opus 4.8 4e94092730 Type UsersTable columns instead of any
UsersTable declared its columns as any[], which masked a few type
mismatches. Type them as ColumnType<lib.UserDetails>[] (matching
InstanceList) and fix what surfaced: the Roles column and onFilterUser
value types (antd keys include bigint) and useUserFilter's filterDropdown
prop type (FilterDropdownProps instead of a hand-written string[] shape).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:55:47 +00:00
bbassieandClaude Opus 4.8 82629a57c6 Always persist table state; drop the persistState prop
Every render site of InstanceList and UsersTable already passed persistState,
so the prop and its uncontrolled fallback branch were dead config. Remove the
prop and the conditionals so these tables always persist their state to the
URL. No runtime behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:40:21 +00:00
bbassieandClaude Opus 4.8 92f607a864 Remember the instance console chat/log toggle
The Console "Chat/Log" filter toggle on the instance page now persists its
state to localStorage, so turning it off (to see full logs) is remembered
across reloads. It still defaults to on (chat only) when nothing is stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:30:12 +00:00
bbassieandClaude Opus 4.8 245483cb35 Adopt agreed URL scheme and drop the dist-based test
Following discussion on the PR, the sort value now packs the direction as
`<field>.<asc|desc>` (e.g. user.sort=name.desc) instead of a separate
`.order` param, page size uses `.size` instead of `.pageSize`, and the
reserved system keys are sort/page/size. `.sort` is read tolerant of repeats
so multi-column sort can be added later without changing the URL shape.

Filterable columns must not use the reserved system names. Removed the
web_ui unit test since it required importing from the built dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:24:31 +00:00
bbassieandClaude Opus 4.8 f04fc68513 Use real links for navigation so they open in new tabs
In-app navigation was done with JS onClick -> navigate() handlers instead of
real <a href> anchors, so the browser's right-click "Open in new tab" (and
middle-click / ctrl-click) was unavailable. (closes #929)

- Sidebar nav items and the account "user" entry now render their label as the
  existing Link component (a real anchor via react-router useHref), keeping the
  Menu onClick for plain left-clicks; stopPropagation avoids double navigation.
- The "back to instances" alert action becomes a Link.
- Table rows (Hosts, Roles, Plugins, Instances, Users, the user instance-stats
  table, Mod Packs): the name cell becomes a real Link (color inherit, so it
  looks unchanged) which carries the right-click context menu, and a new
  useRowNavigation hook handles the whole row — left-click navigates in place,
  middle-click / ctrl-click open the target in a new tab. The hook ignores
  clicks landing on a real link inside the row so the anchor handles those.

Genuine actions (download/delete/modify/logout), post-create navigation and
external links are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:52:15 +00:00
bbassieandClaude Opus 4.8 49eb594e5b Persist table sort/filter state in the URL
Tables such as Hosts, Instances, Users, Roles, etc. now write their sort
order, column filters and pagination into the URL query string, so reload,
link-sharing and browser back/forward restore the same view (closes #927).

Every parameter is namespaced with the table name so the URL is
self-describing and two tables can share a page without colliding, e.g.
?user.sort=name&user.name=bob&user.admin=true or ?instance.name=foo.

Adds two reusable hooks, exported from @clusterio/web_ui so plugins can use
them too:
- useTableQueryState: makes an antd Table controlled and round-trips its
  sort/filter/pagination through the URL via useSearchParams. The pure
  serialisation helpers live in util/tableQuery.ts and are unit tested.
- useColumnSearch: a column-header text-search filter (matching the Users
  table style), persisted through the same filter mechanism.

Applied to Hosts, Roles, Plugins, the instance list (standalone and on the
host detail page), Users (main table and the online-players list on the
instance detail page), instance saves, the per-instance user stats, the mod
packs and stored mods tables, and the subspace_storage storage page. The
storage page's resource search moves from a standalone box into the Resource
column header, keeping its regex matching.

The bundled user filter persists as separate readable params (user.name,
user.admin, ...) via a per-column FilterCodec rather than one JSON blob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:23:54 +00:00
bbassieandClaude Opus 4.8 ed84a476c6 Add top bar and overlay nav drawer on mobile
Reintroduce a slim top bar to host the menu toggle, branding and the
account menu, which gives the hamburger a natural home now that the
header is gone. The bar is more compact than the original header and
uses the dark sider colour for better contrast.

On phone screens the sider becomes an overlay Drawer (opening below the
bar and closing on navigation), following normal hamburger conventions
instead of shifting the content across. On desktop the same toggle
collapses the sider, with its width animation disabled to avoid the
content reflow glitch while resizing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:19:36 +00:00
Cooldude2606 eb77ca566c 2.0.0-alpha.26 2026-06-23 00:39:47 +01:00
bbassieandClaude Opus 4.8 262357acef Rework site layout into a fixed sidebar
Remove the top header and move its contents into the sidebar, which is
now sticky and full height: logo and version at the top, navigation in
the middle, and the account menu pinned to the bottom (opening to the
right).

The sidebar is rebuilt with antd Flex, Typography and Menu so almost all
styling comes from antd, reducing the custom css to a single sticky rule.
The nav menus also use theme="dark" to match the sider and fix the low
contrast active item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:16:49 +00:00
Cooldude2606 9e727f9a13 Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
2026-06-13 15:18:22 +01:00
Cooldude2606 d35e8caa2b Merge pull request #914 from Cooldude2606/feature/view-changelog
Add viewable changelog
2026-06-12 16:14:19 +01:00
Cooldude2606 b9c073e26f Fix typo in ci publish workflow 2026-06-12 16:08:37 +01:00
Cooldude2606 b160fecac4 Make icons local to package 2026-06-12 16:04:39 +01:00
Cooldude2606 dab727f90f Fix import in AboutModal 2026-06-10 22:39:26 +01:00
Cooldude2606 0e36df8ae3 Add AboutModal 2026-06-10 22:35:00 +01:00
Cooldude2606 f7d3516c24 Remove internal div from changelog 2026-06-10 20:18:05 +01:00
Cooldude2606 e6665d0803 Fix incorrect use of path.parse within create template (#919) 2026-06-10 20:01:53 +01:00
Cooldude2606 6fd4654c21 Merge pull request #913 from Cooldude2606/refactor/subscriptions-2
Refactor subscription handling and add new filter classes
2026-06-02 22:51:13 +01:00
Cooldude2606 b5c99fad2a Fix incorrect doc comment 2026-06-02 22:50:41 +01:00
Cooldude2606 f588f7f8d4 Guard against div0 in cpuUsage (#918) 2026-06-02 22:49:45 +01:00
Cooldude2606 4fe83cbcbc Add duration filter to first seen column 2026-06-01 23:12:47 +01:00
Cooldude2606 069a050f39 Unify column for instance and no instance 2026-06-01 23:04:57 +01:00
Cooldude2606 577615803b Add loading={!synced} to table and inputs 2026-06-01 22:45:04 +01:00
Cooldude2606 680ade5975 Add instance filter to users page 2026-06-01 22:44:13 +01:00
Cooldude2606 6914645c19 Make Username reusable and fix active icon issue 2026-06-01 21:58:27 +01:00
Cooldude2606 1d0b6b9a62 Add user status filters 2026-06-01 21:47:33 +01:00
Cooldude2606 0482f4ae08 Added test for invalid assigned host 2026-06-01 01:06:06 +01:00
Cooldude2606 16ce748ffa Improved test coverage 2026-06-01 00:58:25 +01:00
Cooldude2606 2c199028ea Add tests 2026-06-01 00:31:29 +01:00
Cooldude2606 22b16a7ac2 Small refactor of verifyToken effect 2026-06-01 00:13:57 +01:00
Cooldude2606 f2ccde0dd6 Add version and connect button to player auth 2026-05-31 23:55:43 +01:00
Cooldude2606 dd80d49989 Add viewable changelog 2026-05-31 03:52:42 +01:00
Cooldude2606 e3d5f434a4 Better toString and test coverage 2026-05-29 20:14:23 +01:00
Cooldude2606 11b8a0ff97 Fix bad snapshot initialisation 2026-05-29 19:15:27 +01:00
Cooldude2606 e54b292fce Fix tests relating to permissions 2026-05-29 18:38:07 +01:00
Cooldude2606 41222343e3 Add tests 2026-05-29 17:45:14 +01:00
Cooldude2606 4676f1c820 Add filteredHandler to EventSubscriber 2026-05-29 14:09:19 +01:00
Cooldude2606 e49b82842d Add ValueSubscriber and MapSubscriber 2026-05-28 01:12:05 +01:00
Cooldude2606 e0a39e5630 Add option for events to define get filters 2026-05-28 01:11:38 +01:00
Cooldude2606 21b050b220 Call subscriptionUpdate for all update cases 2026-05-28 00:38:15 +01:00
Cooldude2606 9bed1223fd Add option to replace filters 2026-05-28 00:33:45 +01:00
Cooldude2606 4735f0725d Add SubscriptionFilters class 2026-05-28 00:25:29 +01:00
Cooldude2606 1f27617a27 Use addressIndex and a map for subscriptions 2026-05-27 23:35:31 +01:00
Cooldude2606 ee460d677d Rename libAddress.index to requestIndex and add addressIndex 2026-05-27 23:23:29 +01:00
Cooldude2606 f2ca465ec6 Merge pull request #909 from Cooldude2606/fix/small-things
Multiple small changes
2026-05-14 13:08:06 +01:00
Cooldude2606 40e6961711 Fix conflict between defaultFilteredValue and responsive 2026-05-14 12:51:04 +01:00
Cooldude2606 1bb51988ac Fix popconfirm on instance kill button 2026-04-25 23:32:30 +01:00
Cooldude2606 2c30fa4a94 Add user section to changelog script 2026-04-25 22:38:23 +01:00
Cooldude2606 a22186ca01 Add a newline after each section title in change log 2026-04-25 22:19:49 +01:00
Cooldude2606 746b11ff65 Add scrollable div to player auth server list 2026-04-25 22:18:32 +01:00
Cooldude2606 de51741835 Use workspace:^ rather than workspace:* 2026-04-25 22:06:36 +01:00
Cooldude2606 a633c42904 Fix default filter for online users table 2026-04-25 22:03:02 +01:00
Cooldude2606 7522424517 2.0.0-alpha.25 2026-04-24 17:41:16 +01:00
Cooldude2606 57f15d7ba4 Merge pull request #894 from Cooldude2606/fix/892
Fix accessing main inventory when in map view
2026-04-24 11:30:56 +01:00
Cooldude2606 1af672aa1d Always exit remote view on sync 2026-04-23 17:25:13 +01:00
Cooldude2606 41f8f493aa Fix nil table key error in factorio 1.1 2026-04-23 00:19:20 +01:00
Cooldude2606 8be95c140a Fix edge cases with physical location and controller 2026-04-22 22:37:42 +01:00
Cooldude2606 add0088704 Switch to xpcall for better trace backs 2026-04-22 00:49:45 +01:00
Cooldude2606 037f06808e Support remote controller type within deserialize 2026-04-22 00:45:31 +01:00
Daniel 01456818a8 Merge pull request #897 from Cooldude2606/feature/custom-version-input
Allow custom versions to be selected as a target
2026-04-20 22:26:11 +02:00
Cooldude2606 5c3182fb56 Merge pull request #902 from Cooldude2606/feature/improved-role-page
Improved RoleViewPage
2026-04-19 22:52:19 +01:00
Cooldude2606 f515b2db80 Disable the select all and clear buttons using canUpdate 2026-04-19 22:47:05 +01:00
Daniel eb2c2b5aec Merge pull request #901 from Cooldude2606/fix/instance-config-update
Add missing instanceConfigUpdated to InstanceConfigSetRequest
2026-04-19 22:48:22 +02:00
Cooldude2606 6f181f357b Change colour of groups to match input colour 2026-04-19 16:53:27 +01:00
Cooldude2606 1bcc152368 Improved RoleViewPage
Add groups, search, better description format, and default / dangerous icons.
2026-04-19 03:28:09 +01:00
Cooldude2606 26df9d2caa Add missing instanceConfigUpdated to InstanceConfigSetRequest 2026-04-18 22:05:05 +01:00
Cooldude2606 45adb1e88c Merge pull request #896 from Cooldude2606/feature/ctl-double-interrupt
Add double interrupt logic to ctl to force exit
2026-04-15 22:22:58 +01:00
Cooldude2606 6f5b8ce68d Update log statement to fatal 2026-04-15 22:15:14 +01:00
Cooldude2606 16a249d5bf Have version selectors share a common base component 2026-04-14 23:32:16 +01:00
Cooldude2606 a52e4320ec Improve UX on keyboard 2026-04-14 22:50:39 +01:00
Cooldude2606 e8cabb7196 Standardise shutdown signal guarding 2026-04-14 22:10:01 +01:00
Cooldude2606 7e82ad89ff Change config errors to be subclasses of RequestError (#895)
This prevents them from being logged when raised during an event handler.
2026-04-14 21:38:11 +01:00
Cooldude2606 2219b58000 Fix two missed cases of get_main_inventory 2026-04-14 21:31:26 +01:00
Cooldude2606 3667b4733a Fix webui error on setting host config (#893) 2026-04-14 21:21:00 +01:00
Hornwitser 490e9c0e5f Remove download headless Factorio in installer (#890)
This feature was made obsolete by hosts being able to download a
configured target version on instance startup in #831.
2026-04-13 12:27:20 +02:00
Cooldude2606 7e87201c24 Allow custom versions to be selected as a target
Add a custom version input field which can be used to enter any version number.
2026-04-12 23:18:13 +01:00
Cooldude2606 e9653c13eb Add double interrupt logic to ctl to force exit 2026-04-12 22:37:50 +01:00
Cooldude2606 255cc4c325 Fix accessing main inventory when in map view 2026-04-12 22:15:24 +01:00
395 changed files with 19321 additions and 7349 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
"runtime": {
"version": "Lua5.2",
"requirePattern": [ "?", "?.lua" ]
},
"workspace": {
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
"ignoreGlobs": [
"**/node_modules/**",
"**/dist/**",
"database/**",
"docs/**",
"external_plugins/**",
"factorio/**",
"instances/**",
"logs/**",
"mods/**",
"modules/**",
"temp/**",
"packages/create/templates/**"
],
"moduleMap": [
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
]
},
"doc": {
"privateName": [ "__*" ]
},
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
"diagnostics": {
"globals": [ "__DebugAdapter", "__Profiler" ],
"disable": [
"assign-type-mismatch",
"param-type-mismatch",
"preferred-local-alias",
"unnecessary-assert",
"unnecessary-if",
"unused"
]
}
}
+53
View File
@@ -0,0 +1,53 @@
name: Install dependencies
description: Set up pnpm and Node.js, restore caches and run pnpm install
inputs:
node-version:
description: Node.js version passed to actions/setup-node
required: true
ignore-scripts:
description: Skip the prepare scripts that build the workspace packages
default: "false"
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
# The lockfile is not committed, so the usual lockfile hash key does not
# apply. Instead a new store snapshot is saved whenever a package.json
# changes or a week passes, and every run restores the most recent one.
- id: store
shell: bash
run: |
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: ${{ steps.store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-
# The prepare scripts run tsc --build and webpack, both incremental when
# their previous output is present. Every run saves its output and the
# next run starts from the most recent one.
- if: inputs.ignore-scripts != 'true'
uses: actions/cache@v6
with:
path: |
packages/*/dist
plugins/*/dist
packages/*/node_modules/.cache
plugins/*/node_modules/.cache
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
- shell: bash
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
# Drop packages the restored store had but this install did not use, so
# the snapshot does not grow with every version bump.
- shell: bash
run: pnpm store prune
+83
View File
@@ -0,0 +1,83 @@
name: Lua Lint
on:
push:
pull_request:
env:
EMMYLUA_CHECK_VERSION: "0.25.1"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Generate Factorio typedefs
run: |
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
- name: Install emmylua_check
run: |
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP"
- name: Run Lint Report
shell: bash
run: |
# The typedefs are generated per run, so their path is passed via a second config
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
'{ workspace: { library: [ $lib ] } }' \
> "$RUNNER_TEMP/ci.emmyrc.json"
# Exits non zero on error severity, so the gate below is used instead
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
--output-format json --output check.json || true
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
# Although some minor fixes were needed
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
code: .code,
line: .range.start.line,
message: (.message | split("\n")
| map(if startswith("- ") then .[2:] else . end)
| join("\n")),
severity: (if .severity <= 1 then "ERR" else "WARN" end)
} |
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
check.json > "$GITHUB_STEP_SUMMARY"
echo ""
# Github Annotations (limited to 10)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
title: .code,
line: .range.start.line,
endLine: .range.end.line,
col: .range.start.character,
endColumn: .range.end.character,
message: (.message | gsub("\n"; "%0A"; "m")),
level: (if .severity <= 1 then "error" else "warning" end)
} |
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
check.json
# An entry is emitted for every file checked, including clean ones, so
# the gate counts diagnostics and asserts the workspace was actually read
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
exit 1
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
exit 1
else
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
fi
+68 -27
View File
@@ -7,70 +7,111 @@ on:
push:
pull_request:
env:
NODE_VERSION: 24.x
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [20.x, 22.x]
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.47]
# Supported node versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [24.x]
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.77, 2.1.17]
# Coverage is performed on the older of the supported node versions
include:
- node-version: 22.x
factorio-version: 2.1.17
coverage: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
- uses: actions/checkout@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
with:
node-version: ${{ matrix.node-version }}
- name: Prepare and install
run: pnpm i --no-frozen-lockfile
- name: Use Factorio ${{ matrix.factorio-version }}
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
if: ${{ !matrix.coverage }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
if: ${{ matrix.coverage }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
- name: Upload coverage to Codecov
if: ${{ matrix.coverage }}
uses: codecov/codecov-action@v7
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
version-compat:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# npm version spec for the published side of the compatibility test.
# If each release is compatible with the one before it then testing
# against the latest release transitively covers older versions too.
PUBLISHED_VERSION: latest
strategy:
fail-fast: false
matrix:
include:
- controller: local
host: published
- controller: published
host: local
steps:
- uses: actions/checkout@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build
run: pnpm run build
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
run: >
node scripts/version-compat-test.js
--controller ${{ matrix.controller }}
--host ${{ matrix.host }}
--published-version $PUBLISHED_VERSION
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
with:
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
- run: pnpm run lint
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
with:
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
- run: pnpm run build-mod
# Upload build to artifacts
- name: Upload build to artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: clusterio_lib
path: ./dist
+14 -12
View File
@@ -6,22 +6,24 @@ on:
types:
- released
permissions:
id-token: write
contents: write
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22.x
node-version: 24.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@v3
with:
files: pnpm-lock.yaml
@@ -29,16 +31,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22.x
node-version: 24.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@v3
with:
files: ./dist/*
+2 -2
View File
@@ -10,9 +10,8 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directories used by tools like nyc and codecov
# Coverage directories used by tools like c8 and codecov
coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
@@ -38,6 +37,7 @@ node_modules
/config-*.json.lock
pnpm-lock.yaml
mod_cache.json
.luarc.json
/static/
saves
/temp/
-1
View File
@@ -1 +0,0 @@
auto-install-peers=false
+112
View File
@@ -31,6 +31,118 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.27
### Features
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
### Fixes
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
### Changes
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
### Meta
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>)
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20 as subspace_storage_builder
FROM node:24 AS subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/subspace_storage.git
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:20 as clusterio_builder
FROM node:24 AS clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
+6 -8
View File
@@ -86,6 +86,8 @@ There's also plugins developed and maintained by the community:
Want to make your own plugin?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
## Installation
@@ -167,16 +169,12 @@ Your instances (save files etc) will be stored there.
### Installing Plugins
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
Installing plugins that are not offered by the installer consists of two steps.
First install the package the plugin is provided by via npm, for example
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
npm install @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
+6 -7
View File
@@ -6,12 +6,11 @@
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
"use strict";
const path = require("path");
const fs = require("node:fs/promises");
const yargs = require("yargs");
import path from "node:path";
import fs from "node:fs/promises";
import yargs from "yargs";
const npmPackage = require("./package.json");
import npmPackage from "./package.json" with { type: "json" };
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
@@ -75,7 +74,7 @@ async function removeTestArtifacts() {
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-control.json", "config-controller.json", "config-host.json",
"config-ctl.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
@@ -116,6 +115,6 @@ async function main() {
}
// Run main if started from command line
if (module === require.main) {
if (import.meta.main) {
main();
}
+41 -2
View File
@@ -3,10 +3,10 @@
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
Plugins can add their own config fields for the controller and instances configs.
Plugins can add their own config fields for the controller, host and instance configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
@@ -46,6 +46,45 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
**validator** (optional):
Function called with the value being set and the config it belongs to.
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
The config is read only while the validator runs.
**dependsOn** (optional):
Names of other fields the validator reads the value of.
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
**restartRequired** (optional):
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
Defaults to false.
**restartRequiredProps** (optional):
For object fields, names of properties that have the opposite of `restartRequired`.
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
**credential** (optional):
Locations allowed to read the value of this field.
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
**readonly** (optional):
Locations allowed to change the value of this field.
Other locations can still read the value if allowed by `access`.
**hidden** (optional):
True if the web interface should not show this field.
Used for internal values that are managed by Clusterio.
**autoComplete** (optional):
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
Only used for string and number fields.
**inputComponent** (optional):
Name of the input component the web interface should render for this field instead of the default for its type.
Plugins can add their own components through `inputComponents` on their web plugin class.
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
+357 -17
View File
@@ -1,5 +1,15 @@
# Configuration
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
All values are checked against the type of the field when set.
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
Setting a value that fails a check is rejected with an error and the old value is kept.
A few fields are internal to Clusterio.
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
## Controller Configuration
@@ -20,11 +30,30 @@ This is also used by plugins to store its data.
Defaults to "database".
### controller.mods_directory
Directory where mods shared with the cluster are stored.
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
Needs to be writeable by the controller.
Defaults to "mods".
### controller.autosave_interval
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
The data is also saved on shutdown.
Must be greater than 0.
Defaults to 60.
### controller.http_port
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and controller.https_port needs to be set to a port.
Must be a whole number greater than 0.
Defaults to 8080.
@@ -35,6 +64,7 @@ Port to host HTTPS server on.
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and controller.http_port needs to be set to a port.
Must be a whole number greater than 0.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
@@ -51,27 +81,56 @@ interfaces.
Defaults to null.
### controller.trusted_proxies
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
Entries that are not valid IP addresses are logged as errors and ignored.
Defaults to null meaning the header is never trusted.
### controller.public_url
Public address the controller is accessible on.
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### controller.static_url
Used to override where static assets such as the web bundle is loaded from.
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
Defaults to null meaning use the internally hosted assets.
### controller.grafana_url
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
If null no link is shown.
Defaults to null.
### controller.tls_certificate
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
Defaults to "database/certificates/cert.crt".
Defaults to null.
### controller.tls_private_key
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
Defaults to "database/certificates/cert.key".
Defaults to null.
### controller.auth_secret
@@ -79,6 +138,7 @@ Defaults to "database/certificates/cert.key".
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
Defaults to null which means generate a secure secret on startup.
@@ -88,6 +148,7 @@ Defaults to null which means generate a secure secret on startup.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Must be greater than 0.
Defaults to 15.
@@ -99,25 +160,94 @@ A higher timeout gives more time for a client to recconect and resume an active
In the case of the controller or client crashing the data loss is unavoidable.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
Must be greater than 0.
Defaults to 60.
### controller.max_send_buffer_size
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
The controller keeps these so they can be resent if the connection drops and resumes.
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
Changes apply to new sessions.
Set to 0 for no limit.
Defaults to 64.
### controller.metrics_timeout
Timeout in seconds before a call to gather metrics from a host times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
Must be greater than 0.
Defaults to 8.
### controller.stream_timeout
### controller.max_subscription_filters
Maximum number of filters one connection can hold for a single subscribed event.
Subscription requests that would go over this limit are rejected.
Set to 0 for no limit.
Lowering the limit does not remove filters connections already hold.
Defaults to 10000.
### controller.system_metrics_interval
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
Whether the controller needs a restart to apply an update is also checked on this interval.
If 0 system metrics are not gathered.
Must be 0 or greater.
Defaults to 10.
### controller.proxy_stream_timeout
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Must be greater than 0.
Defaults to 15.
### controller.factorio_username
Username of the Factorio.com account to authenticate with the Factorio API with.
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
Defaults to null.
### controller.factorio_token
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
This is the token shown on your profile page on factorio.com, not your password.
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
Defaults to null.
### controller.share_factorio_credentials_with_hosts
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
Disable this if you don't want the controller's credentials to be sent to the hosts.
Defaults to true.
### controller.default_mod_pack_id
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
Instances without a mod pack of their own fail to start if this is null.
Defaults to null.
### controller.default_role_id
Role to automatically grant to new users.
@@ -126,6 +256,55 @@ If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### controller.mod_portal_cache_duration_minutes
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
Set to 0 to disable the cache and fetch the list on every request.
Must be 0 or greater.
Defaults to 30.
### controller.mod_portal_page_size
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
Must be greater than 0.
Defaults to 1000.
### controller.allow_remote_updates
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### controller.allow_plugin_updates
Whether remote requests to update plugins installed on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to true.
### controller.allow_plugin_install
Whether remote requests to install new plugins on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to false.
### controller.version
Version of the controller package that last loaded this config.
Set automatically on startup and used to detect when the controller has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the controller.
@@ -147,6 +326,7 @@ Defaults to "New Host".
### host.id
Immutable numeric id of the host which uniquely identifies this host in the cluster.
Hidden in the web interface.
Defaults to a random 31 bit number.
@@ -159,6 +339,14 @@ This can either point to a Factorio server directory, or to a directory containi
Defaults to "factorio".
### host.mods_directory
Directory the host stores mods downloaded from the controller in.
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
Defaults to "mods".
### host.instances_directory
Directory to store instances in.
@@ -178,6 +366,7 @@ Defaults to "http://localhost:8080/".
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
@@ -190,14 +379,82 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
### host.factorio_port_range
UDP ports the host hands out to instances that don't have factorio.game_port set.
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
Starting an instance fails if all ports in the range are taken.
Defaults to "34100-34199".
### host.factorio_username
Username of the Factorio.com account to put in the server settings of instances on this host.
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
Defaults to null.
### host.factorio_token
Token of the Factorio.com account to go with host.factorio_username.
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
Defaults to null.
### host.max_reconnect_delay
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be greater than 0.
Defaults to 60.
### host.allow_remote_updates
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### host.allow_plugin_updates
Whether remote requests to update plugins installed on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to true.
### host.allow_plugin_install
Whether remote requests to install new plugins on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to false.
### host.version
Version of the host package that last loaded this config.
Set automatically on startup and used to detect when the host has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the host.
Only plugins with a host or instance part have this field in the host config.
Plugins not loaded on the host will not be loaded on the instances it hosts.
Defaults to true.
## Instance Configuration
### instance.name
@@ -210,6 +467,7 @@ Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
Hidden in the web interface.
Defaluts to a random 31 bit integer.
@@ -219,6 +477,7 @@ Defaluts to a random 31 bit integer.
Host this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a host the directory for the instance is created on the host.
Internal and hidden in the web interface.
Defaults to null meaning not assigned.
@@ -242,23 +501,52 @@ Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
Values that don't match one of these forms are rejected.
Defaults to "latest".
### factorio.executable_path
Path relative to the Factorio installation directory of the executable to run.
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
Only needed in special setups.
Defaults to null.
### factorio.shutdown_timeout
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
Set to 0 to wait forever.
Changing this while the instance is stopping restarts the timer with the new value.
Must be 0 or greater.
Defaults to 300.
### factorio.game_port
UDP port to run the Factorio server on.
If null a random port in the dynamyc range assigned each time the instance starts.
If null a port from host.factorio_port_range on the host is assigned to the instance.
Negative values are rejected.
Defaults to null.
### factorio.host_assigned_game_port
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
Internal, set by the host and only accessible from the host.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Negative values are rejected.
Defaults to null.
@@ -267,16 +555,18 @@ Defaults to null.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
The value is treated as a credential and can only be read back by the controller and the host.
Defaults to null.
### player_online_autosave_slots
### factorio.player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
The same check is done when factorio.settings is changed.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
@@ -284,12 +574,34 @@ If 0 no autosave renaming takes place.
Defaults to 5.
### factorio.mod_pack_id
ID of the mod pack to use on this instance.
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
If null the mod pack set in controller.default_mod_pack_id is used.
Defaults to null.
### factorio.enable_save_patching
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Plugins that need save patching can't be loaded on the instance while this is disabled.
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
Defaults to true.
### factorio.enable_script_commands
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
When disabled attempts to send such a command fail with an error.
This does not stop players from using these commands themselves.
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
Defaults to true.
@@ -301,6 +613,13 @@ Turn on the whitelist on the server.
Defaults to false.
### factorio.enable_authserver_bans
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the Factorio server.
@@ -308,7 +627,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
### factorio.verbose_logging
@@ -319,6 +638,14 @@ This prints more messages to the log.
Defaults to false.
### factorio.console_logging
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
Useful for 3rd party tools that read the console log of the server.
Defaults to false.
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
@@ -341,25 +668,35 @@ Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to true.
Defaults to "bidirectional".
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to true.
Defaults to "bidirectional".
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to true.
Defaults to "bidirectional".
### factorio.max_concurrent_commands
@@ -376,6 +713,7 @@ This means a high number of concurrent commands can amplify resends due to packe
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
Must be greater than 0.
Defaults to 5.
@@ -383,21 +721,22 @@ Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
Defaults to true.
## Control Configuration
## Ctl Configuration
### control.controller_url
### ctl.controller_url
URL to connect to the controller to.
Defaults to null meaning complain about it not being set and exit.
### control.controller_token
### ctl.controller_token
Access token used for authenticating with the controller.
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
@@ -405,9 +744,10 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### control.max_reconnect_delay
### ctl.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be 0 or greater.
Defaults to 60.
+8 -3
View File
@@ -77,7 +77,7 @@ The documentation uses the binaries provided by the packages, but for the develo
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
Note that to use `clusterioctl` you will have to create a control config first:
Note that to use `clusterioctl` you will have to create a ctl config first:
node packages/controller bootstrap create-ctl-config [admin-user]
@@ -125,6 +125,11 @@ Please do add tests for any code you add and run both:
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
The full test suite can take a while to run.
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
### Submitting a Pull Request
@@ -224,8 +229,8 @@ Files containing and exporting a single class should be named the same as the cl
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
const libLink = require("./link");
const libLuaTools = require("./lua_tools");
import libLink from "./link";
import libLuaTools from "./lua_tools";
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
+7 -7
View File
@@ -361,16 +361,16 @@ While we have considered alternatives since, transitioning to another solution w
### Breaking Changes
🧾 Time Immemorial
🧾 7th October 2026
> We follow symver versioning for breaking changes.
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
> Once we enter beta, breaking changes will be more restricted.
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
> All breaking changes are documented in the changelog along with migration actions.
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
Changes to the plugin API may still be made in minor versions.
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
### Distribution
+3 -8
View File
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
* A Data class example
* @alias module:Data
*/
class Data {
export class Data {
/** @type {string} */
member = "foo";
@@ -41,8 +41,6 @@ class Data {
return json;
}
}
module.exports = Data;
```
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
@@ -53,14 +51,13 @@ For example a class holding a data property with an instance of the Data class f
```js
// Composed.js
"use strict";
const Data = require("./Data");
import Data from "./Data";
/**
* Composed data class example
* @alias module:Data
*/
class Composed {
export class Composed {
/** @type {module:Data|undefined} */
data;
@@ -83,8 +80,6 @@ class Composed {
// toJSON is optional and not needed for this particular class
}
module.exports = Composed;
```
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
+327
View File
@@ -0,0 +1,327 @@
# Migrating Plugins to 2.0
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
## Versioning from 2.0
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
## Convert to ES modules
Clusterio's packages are now ES modules and plugins should be too.
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
Relative imports must include the full filename with the `.js` extension.
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
Importing a directory no longer resolves to its `index.js`.
**Alpha 27**
```js
"use strict";
const lib = require("@clusterio/lib");
const messages = require("./messages");
const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller",
messages: [messages.Frobnicate],
};
module.exports = {
plugin,
};
```
**2.0**
```js
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
export const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller.js",
messages: [messages.Frobnicate],
};
```
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
Converting the plugin avoids the problem.
## Replace plugin classes with entrypoint functions
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
Class exports still load, but they are deprecated and a warning is logged for each one.
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
**Alpha 27**
```js
const { BaseControllerPlugin } = require("@clusterio/controller");
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
}
async onSaveData() {
await this.saveFrobnicationData();
}
async onInstanceStatusChanged(instance, prev) {
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
}
async handleFrobnicate(request) { /* ... */ }
}
module.exports = {
ControllerPlugin,
};
```
**2.0 (JS)**
```js
export default async function(context) {
const { controller, logger, plugin } = context;
controller.handle(Frobnicate, async (request) => { /* ... */ });
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
});
}
```
**2.0 (TS)**
```ts
import type { ControllerPluginContext } from "@clusterio/controller";
export default async function(context: ControllerPluginContext) {
// Same as above
}
```
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
### Keeping a class for state
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
`fromContext` can then be the default export of the entrypoint.
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
```ts
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
import type * as lib from "@clusterio/lib";
export class Frobber {
private static instances = new WeakMap<Controller, Frobber>();
private frobnications = new Map<string, number>();
private constructor(
private controller: Controller,
private logger: lib.Logger,
) { }
static async fromContext(context: ControllerPluginContext) {
const frobber = new Frobber(context.controller, context.logger);
await frobber.load();
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
Frobber.instances.set(context.controller, frobber);
return frobber;
}
static get(controller: Controller) {
return Frobber.instances.get(controller);
}
async load() { /* ... */ }
async save() { /* ... */ }
}
export default Frobber.fromContext;
```
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
### Method to hook names
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
The exceptions are:
- `onSaveData` on the controller is `controller.hooks.save`.
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
The ctl hooks are passed directly as `context.hooks`.
**Alpha 27**
```jsx
class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
];
}
}
```
**2.0**
```jsx
export default async function(context) {
const { control, plugin } = context;
control.hooks.pages.attach(plugin.name, () => [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
]);
}
```
### Replacing other class members
The class properties are on the context object passed to the entrypoint:
- `this.info` is `context.plugin`.
- `this.logger` is `context.logger`.
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
- `this.metrics` on controller plugins is `context.metrics`.
- `this.package` on web plugins is `context.package`.
The helper methods of the base classes have the following replacements:
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
The plugin name is used to label the RCON metrics.
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
- `this.sendOrderedRcon(message)` has no replacement.
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
### Changes to hook behaviour
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
Don't rely on another plugin's handler having finished before yours runs.
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
Each hook accepts one handler per name, attach it under `plugin.name`.
## Accessing other plugins
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
```js
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
const loaded = info !== undefined && controller.loadedPlugins.has(info);
```
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
`control.plugins` is deprecated and only holds plugins still using the class export.
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
```ts
import { Frobber } from "other_plugin/dist/node/controller.js";
const frobber = Frobber.get(controller);
if (!frobber) {
throw new Error("other_plugin is not loaded");
}
```
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
The web plugin template created by `@clusterio/create` shows this for a subscriber.
## Declare permissions on the plugin
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
`lib.definePermission()` still works, but don't use both for the same permission name.
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
```ts
declare module "@clusterio/lib" {
export interface Permissions {
"foo_frobber.frobnicate": never;
}
}
```
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
## Renamed ctl classes and config
The remaining uses of "control" for clusterioctl were renamed to "ctl":
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
Fields under the old name are silently ignored.
- The `ControlConfigFields` interface is `CtlConfigFields`.
- `lib.ControlConfig` is `lib.CtlConfig`.
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
## Static assets in the web interface
`staticRoot` now includes the `static/` prefix.
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
## Package dependencies
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
```json
"peerDependencies": {
"@clusterio/controller": "^2.0.0",
"@clusterio/host": "^2.0.0",
"@clusterio/lib": "^2.0.0"
},
"peerDependenciesMeta": {
"@clusterio/controller": {
"optional": true
},
"@clusterio/host": {
"optional": true
}
}
```
+1 -1
View File
@@ -14,7 +14,7 @@ All messages sent and received are validated with JSON schemas to catch potentia
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
For ctl there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
Messages received invokes message handlers that were registered when the link was brought up.
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
+2
View File
@@ -5,6 +5,8 @@ The save patching is based on the built in [event_handler](https://github.com/wu
Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
+48 -1
View File
@@ -5,7 +5,7 @@ This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` s
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
## Hosts
@@ -43,6 +43,14 @@ The controller also supports remote updates, following the same logic. The corre
ctl> controller update [--restart]
controller> config set controller.allow_remote_updates true/false
### Mark restart required
ctl> host mark-restart-required <host>
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
ctl> controller mark-restart-required
## Instances
### List Instances
@@ -65,6 +73,12 @@ Terminates the running Factorio server without giving it a chance to save or cle
Note: This may cause loss of data.
### Save game
ctl> instance save-game <name>
Saves the running game of the instance to the save it was started from and waits for the save to finish.
### Extract players from instance
ctl> instance extract-players <name>
@@ -241,6 +255,13 @@ This command lists all plugins currently known to the remote. Newly installed pl
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Update All
ctl> controller update --all [--restart]
ctl> host update <host> --all [--restart]
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
### Install
ctl> controller plugin install <name> [--restart]
@@ -261,3 +282,29 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
controller> config set controller.allow_plugin_install true/false
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
## Prometheus Metrics
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
Create a role with just this permission, a user to hold it, and generate a token for that user.
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
ctl> role create Prometheus --permissions core.controller.metrics
ctl> user create #prometheus
ctl> user set-roles #prometheus Prometheus
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
Then pass the token in the scrape job for the controller in `prometheus.yml`:
scrape_configs:
- job_name: clusterio
authorization:
credentials: <token>
static_configs:
- targets: ["localhost:8080"]
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
+194 -103
View File
@@ -1,15 +1,16 @@
# Writing Plugins
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
Plugins for Clusterio are JavaScript modules that run under Node.js.
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
## Contents
- [Plugin Structure](#plugin-structure)
- [Defining the plugin class](#defining-the-plugin-class)
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
- [Plugin Permissions](#plugin-permissions)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
@@ -27,7 +28,7 @@ The basic file structure of a plugin is the following.
+- package.json
+- controller.js
+- instance.js
+- control.js
+- ctl.js
+- test/
| +- plugin.js
+- module/
@@ -48,12 +49,12 @@ By default the main entrypoint is the `index.js` file, but this may be changed b
Here's an example of `index.js`:
```js
module.exports.plugin = {
export const plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance",
controllerEntrypoint: "controller",
instanceEntrypoint: "instance.js",
controllerEntrypoint: "controller.js",
messages: {
/* See below */
},
@@ -75,7 +76,7 @@ The following properties are recognized:
Currently not used.
**instanceEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
@@ -84,7 +85,7 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**controllerEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the controller.
@@ -93,77 +94,90 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**ctlEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
This is an optional paramater.
A plugin can be made that only runs on the clusterioctl side.
**features**:
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
See [Handling Invalid Configuration](#handling-invalid-configuration)
**messages**:
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
**permissions**:
Array of permission definitions for this plugin.
See [Plugin Permissions](#plugin-permissions)
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
You can put them into whatever file you want (even the same one for both).
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
## Defining the plugin class
## Defining the plugin entrypoint
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a ControllerPlugin class the following code can be used:
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
For example, a controller entrypoint can look like this:
```js
const { BaseControllerPlugin } = require("@clusterio/controller");
export default async function(context) {
const { controller, logger, plugin } = context;
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
}
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
// ...
}
module.exports = {
ControllerPlugin,
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("Stopping frobnication");
});
}
```
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
The available hooks that you can override are documented in the base classes.
The context has the following properties:
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
**plugin**:
The plugin's own info, as exported from the main entrypoint.
```js
constructor(...args) {
super(...args);
**logger**:
Logger for this plugin, see [Logging Messages](#logging-messages).
// Code here
}
```
**controller**, **host**, **instance**, **hooks** or **control**:
The object the entrypoint is loaded for.
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
The async init method is always called immediatly after the constructor, so there's little reason to do this.
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
### Class based plugins
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
## Logging Messages
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
The load context provides a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the host the instance is on.
To use it, pass a string to one of the log levels functions, for example:
```js
async init() {
this.logger.info("Initializing frobbing");
export default async function(context) {
context.logger.info("Initializing frobbing");
}
```
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
The available levels are fatal, error, warn, audit, info, and verbose.
These levels have roughly the following meanings:
@@ -189,7 +203,7 @@ For guarding unexpected errors the best option is to log a short description alo
try {
// Operation that should not throw but may end up throwing
} catch (err) {
this.logger.error(`Operation failed:\n${err.stack}`);
logger.error(`Operation failed:\n${err.stack}`);
)
```
@@ -213,7 +227,7 @@ declare module "@clusterio/lib" {
}
}
export default {
export const plugin = {
...
controllerConfigFields: {
"foo_frobber.level": {
@@ -225,12 +239,12 @@ export default {
} satisfies lib.PluginDeclaration;
```
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
```ts
async init() {
let level = this.controller.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
export default async function(context: ControllerPluginContext) {
const level = context.controller.config.get("foo_frobber.level");
context.logger.info(`I got a frobnication level of ${level}`);
}
```
@@ -240,33 +254,76 @@ See [Configuration System](config-system.md) for more details on how this system
### Handling Invalid Configuration
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
For example:
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
```js
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
export const plugin = {
name: "foo_frobber",
// ...
features: ["SavePatching"],
};
```
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
See [Configuration System](config-system.md) for details.
## Plugin Permissions
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
Names must start with the plugin name followed by a dot.
For example in index.ts:
```ts
import type * as lib from "@clusterio/lib";
declare module "@clusterio/lib" {
// Extend the interface of known permission names so that
// user.checkPermission(...) accepts our permission.
export interface Permissions {
"foo_frobber.frobnicate": never;
}
}
export const plugin = {
...
permissions: [
{
name: "foo_frobber.frobnicate",
title: "Frobnicate",
description: "Run frobnication on instances.",
grantByDefault: false, // Whether the generated Player role is granted this permission
},
],
} satisfies lib.PluginDeclaration;
```
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
In JavaScript the `declare module` block is not needed.
Permissions declared this way are registered when the plugin is loaded.
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
## Communicating with Factorio
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
This is done by calling the `sendRcon` method on the plugin object.
This is done by calling the `sendRcon` method on the instance.
For example:
```js
async onStart() {
let response = await this.sendRcon(
instance.hooks.start.attach(plugin.name, async () => {
const response = await instance.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
}
});
```
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
@@ -279,15 +336,16 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
For example in the plugin code:
```js
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => this.logger.error(
export default async function(context) {
const { instance, logger } = context;
instance.server.on("ipc-my_plugin_foo", content =>
handleFoo(content).catch(err => logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
async handleFoo(content) {
async function handleFoo(content) {
// Do stuff with content
}
```
@@ -304,11 +362,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
For convience a helper function is exposed on server specifically for handling IPC events.
```js
async init() {
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
export default async function(context) {
context.instance.server.handle("my_plugin_foo", handleFoo);
}
async handleFoo(content) {
async function handleFoo(content) {
// Do stuff with content
}
```
@@ -445,15 +503,34 @@ Link messages are sent by calling the `.sendTo()` method on a connection object
For example:
```js
// In an InstancePlugin class
async frobnicate() {
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
// In an instance entrypoint
async function frobnicate(instance) {
const response = await instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
console.log(response); // { report: [...] }
}
```
Because the send and handle methods rely on this, you should always call them directly on the connection object.
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
Here is what that looks like in practice:
```js
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
const send = instance.sendTo;
send("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
```
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
@@ -474,23 +551,23 @@ sendTo({ controlId: 123 }, message);
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between controller and host.
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
```js
if (this.host.connected) {
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
if (host.connected) {
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
}
```
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
@@ -506,7 +583,7 @@ In its simplest form collecting data from plugins consists of defining the metri
For example:
```js
const { Counter } = require("@clusterio/lib");
import { Counter } from "@clusterio/lib";
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
@@ -516,13 +593,13 @@ const fooMetric = new Counter(
fooMetric.inc();
```
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
```js
const { Counter } = require("@clusterio/lib");
import { Counter } from "@clusterio/lib";
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
@@ -530,23 +607,48 @@ const barMetric = new Gauge(
);
// Somewhere in the instance plugin code
barMetric.labels(String(this.instance.id)).set(someValue);
barMetric.labels(String(instance.id)).set(someValue);
```
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
```js
import { Gauge } from "@clusterio/lib";
const frobnicationsMetric = new Gauge(
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
);
// In the controller entrypoint
controller.hooks.metrics.attach(plugin.name, async () => {
frobnicationsMetric.set(frobnications.size);
});
```
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
```js
import { CollectorRegistry, Counter } from "@clusterio/lib";
const fooRegistry = new CollectorRegistry();
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
fooRegistry.register(fooCounter);
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
```
## Adding Custom Commands to clusterioctl
The control entrypoint for plugins allows you to extend clustectl with your own commands.
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
The creation of custom commands typically starts with defining a command tree for the plugin:
```js
const { Command, CommandTree } = require("@clusterio/lib");
import { Command, CommandTree } from "@clusterio/lib";
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
@@ -555,41 +657,30 @@ const fooFrobberCommands = new CommandTree({
Then commands are added to the the plugin's command tree:
```js
const info = require("./info");
import * as messages from "./messages.js";
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("level", {
yargs.positional("type", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
handler: async function(args, ctl) {
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
},
}));
```
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
```js
const { BaseCtlPlugin } = require("@clusterio/ctl");
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
export default async function(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(fooFrobberCommands);
}
}
module.exports = {
CtlPlugin,
});
}
```
+3 -2
View File
@@ -148,6 +148,7 @@ export default defineConfig([{
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
"newIsCapExceptions": ["constructor"],
}],
"new-parens": "error",
@@ -418,7 +419,7 @@ export default defineConfig([{
...globals.mocha,
},
sourceType: "commonjs",
sourceType: "module",
parserOptions: {},
},
@@ -489,7 +490,7 @@ export default defineConfig([{
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
+32 -29
View File
@@ -2,7 +2,8 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"type": "module",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
@@ -10,23 +11,25 @@
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
"cover": "c8 pnpm test",
"fast-cover": "FAST_TEST=y c8 pnpm test",
"ci-cover": "c8 --reporter=lcovonly pnpm test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
"nyc": {
"c8": {
"exclude": [
"test/**",
"plugins/*/test/**"
"plugins/*/test/**",
"temp/**"
]
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"workspaces": [
"packages/*",
@@ -35,29 +38,29 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.3",
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@swc/core": "catalog:",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"c8": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"eslint": "^10.10.0",
"eslint-plugin-node": "^11.1.0",
"express": "^5.1.0",
"form-data": "^4.0.4",
"globals": "^16.3.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
"express": "catalog:",
"form-data": "^4.0.6",
"globals": "^17.12.0",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"mocha": "^11.8.0",
"typedoc": "^0.28.20",
"typescript": "catalog:",
"yargs": "catalog:"
}
}
+37 -6
View File
@@ -15,9 +15,12 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the controller.
Defaults to `config-controller.json` and will be created if it does not exist.
Defaults to `config-controller.json` and is required for operation.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -25,17 +28,28 @@ Configure plugins available to be loaded by the controller.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, installing the Subspace Storage plugin:
For example, adding a plugin checked out in the external_plugins directory:
npm install @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
npx clusteriocontroller plugin add ./external_plugins/my_plugin
#### `plugin remove <name>`
@@ -63,6 +77,13 @@ Manage the controller configuration offline.
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
@@ -104,6 +125,16 @@ This can be used both in the web interface and as the `controller_token` to conn
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
### `copy-static [target]`
Copies the static web assets to the given target folder (which defaults to `./static/`).
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
See also the `controller.public_url` config option for setting where static assets are loaded from.
### `run`
Runs the controller.
+84 -66
View File
@@ -18,7 +18,6 @@ import path from "path";
import fs from "node:fs/promises";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -28,12 +27,12 @@ import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
const { ConsoleTransport, levels, logger } = lib;
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
import Controller from "./src/Controller.js";
import UserManager from "./src/UserManager.js";
import UserRecord from "./src/UserRecord.js";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
// globals
let controller: Controller;
@@ -102,6 +101,9 @@ void new lib.Gauge(
"clusterio_controller_websocket_active_connections",
"How many WebSocket connections are currently open to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.activeConnectors.size);
}}
);
@@ -110,6 +112,9 @@ void new lib.Gauge(
"clusterio_controller_active_hosts",
"How many hosts are currently connected to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.hostConnections.size);
}}
);
@@ -119,6 +124,9 @@ void new lib.Gauge(
"How many clients are currently connected to this controller",
{
labels: ["type"], callback: async function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.labels("host").set(controller.wsServer.hostConnections.size);
gauge.labels("control").set(controller.wsServer.controlConnections.size);
},
@@ -193,25 +201,59 @@ async function handleBootstrapCommand(
process.exitCode = 1;
return;
}
let controlConfig = new lib.ControlConfig("control");
let ctlConfig = new lib.CtlConfig("control");
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
ctlConfig.set(
"ctl.controller_token",
userManager.signUserToken(admin),
);
let content = JSON.stringify(controlConfig, null, "\t");
let content = JSON.stringify(ctlConfig, null, "\t");
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await lib.safeOutputFile(args.output, content);
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
}
}
}
async function handleCopyStaticCommand(
args: any,
pluginInfos: lib.PluginNodeEnvInfo[],
) {
await fs.cp(
path.join(import.meta.dirname, "..", "web", "static"),
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
logger.info("Copied main web interface files");
for (const pluginInfo of pluginInfos) {
try {
await fs.cp(
pluginInfo.webStaticPath,
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
} else {
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
process.exitCode = 1;
}
}
logger.info(`Copied web interface files for ${pluginInfo.name}`);
}
}
export interface ControllerArgs {
[x: string]: unknown;
@@ -235,7 +277,7 @@ interface InitializeParameters {
async function initialize(): Promise<InitializeParameters> {
// argument parsing
const args = await yargs
const args = await yargs(process.argv.slice(2))
.scriptName("controller")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -257,6 +299,12 @@ async function initialize(): Promise<InitializeParameters> {
default: "config-controller.json",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -280,11 +328,18 @@ async function initialize(): Promise<InitializeParameters> {
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
nargs: 1, default: "config-control.json",
nargs: 1, default: "config-ctl.json",
});
})
.demandCommand(1, "You need to specify a command to run");
})
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
yargs.positional("target", {
describe: "Target directory to copy files to",
type: "string",
default: "static",
});
})
.command("run", "Run controller", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
@@ -300,7 +355,7 @@ async function initialize(): Promise<InitializeParameters> {
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.argv
.parse()
;
// Combined log stream of the whole cluster.
@@ -337,7 +392,7 @@ async function initialize(): Promise<InitializeParameters> {
let command = args._[0];
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${version}`);
logger.info(`Starting Clusterio controller ${packageConfig.version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
@@ -362,31 +417,16 @@ async function initialize(): Promise<InitializeParameters> {
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
args,
lib.ControllerConfig,
"controller",
);
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
}
}
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
@@ -400,6 +440,8 @@ async function initialize(): Promise<InitializeParameters> {
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "copy-static") {
await handleCopyStaticCommand(args, pluginInfos);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
@@ -446,33 +488,9 @@ async function startup() {
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
@@ -519,6 +537,6 @@ ${err.stack}`
});
}
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
+14 -14
View File
@@ -1,17 +1,17 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceRecord } from "./src/InstanceRecord";
export { default as InstanceManager } from "./src/InstanceManager";
export { default as HostRecord } from "./src/HostRecord";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as UserRecord } from "./src/UserRecord";
export { default as User } from "./src/User";
export { default as WsServer } from "./src/WsServer";
import { bootstrap } from "./controller.js";
export { default as Controller } from "./src/Controller.js";
export { default as ControllerRouter } from "./src/ControllerRouter.js";
export { default as ControlConnection } from "./src/ControlConnection.js";
export { default as HostConnection } from "./src/HostConnection.js";
export { default as InstanceRecord } from "./src/InstanceRecord.js";
export { default as InstanceManager } from "./src/InstanceManager.js";
export { default as HostRecord } from "./src/HostRecord.js";
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
export { default as UserManager } from "./src/UserManager.js";
export { default as UserRecord } from "./src/UserRecord.js";
export { default as User } from "./src/User.js";
export { default as WsServer } from "./src/WsServer.js";
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
+34 -21
View File
@@ -1,9 +1,28 @@
{
"name": "@clusterio/controller",
"version": "2.0.0-alpha.27",
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"license": "MIT",
"version": "2.0.0-alpha.24",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -12,50 +31,44 @@
"bin": {
"clusteriocontroller": "./dist/node/controller.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"compression": "^1.8.2",
"express": "catalog:",
"finalhandler": "^1.3.1",
"finalhandler": "^2.1.1",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"set-blocking": "catalog:",
"semver": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "^17.7.2"
"yargs": "catalog:"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/busboy": "^1.5.4",
"@types/compression": "^1.8.1",
"@types/express": "catalog:",
"@types/finalhandler": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/semver": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"@types/yargs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-dev-middleware": "^6.1.1",
"webpack-dev-middleware": "^6.1.3",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
}
}
+10 -5
View File
@@ -1,12 +1,12 @@
import path from "path";
import fs from "node:fs/promises";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import * as routes from "./routes";
import { logger } from "@clusterio/lib";
import * as routes from "./routes.js";
/**
@@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -79,7 +80,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -113,4 +114,8 @@ export default class BaseConnection extends lib.Link {
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
return await this._controller.latestReleases.get(request.maxAgeMs);
}
}
+126 -50
View File
@@ -1,45 +1,35 @@
import type {
CollectorResult, Event, InstanceStatus, Logger,
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceRecord from "./InstanceRecord";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
import type Controller from "./Controller.js";
import type InstanceRecord from "./InstanceRecord.js";
import type ControlConnection from "./ControlConnection.js";
import type HostConnection from "./HostConnection.js";
import * as lib from "@clusterio/lib";
export type ControllerPluginContext = lib.PluginLoadContext<{
controller: Controller;
metrics: any;
}>;
/**
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
* Collection of controller plugin hooks
*/
export default class BaseControllerPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
public info: PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
logger: Logger
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
export class ControllerHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.save = this.newHook();
this.metrics = this.newHook();
this.shutdown = this.newHook();
this.instanceStatusChanged = this.newHook();
this.instanceConfigFieldChanged = this.newHook();
this.controllerConfigFieldChanged = this.newHook();
this.controlConnectionEvent = this.newHook();
this.hostConnectionEvent = this.newHook();
this.prepareHostDisconnect = this.newHook();
this.modPacksUpdated = this.newHook();
this.modsUpdated = this.newHook();
this.rolesUpdated = this.newHook();
this.playerEvent = this.newHook();
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the controller saves data in memory to disk
*
@@ -51,7 +41,7 @@ export default class BaseControllerPlugin {
* BaseControllerPlugin.onShutdown} have been invoked and all links have
* been disconnected.
*/
async onSaveData() { }
readonly save: lib.AsyncHook<[]>;
/**
* Called when the status of an instance changes
@@ -91,7 +81,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
/**
* Called when the value of a controller config field changed.
@@ -103,7 +93,7 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
/**
* Called when the value of an instance config field changed.
@@ -117,7 +107,8 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
// eslint-disable-next-line max-len
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
/**
* Called before collecting Prometheus metrics
@@ -134,12 +125,12 @@ export default class BaseControllerPlugin {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called when the controller is shutting down
*/
async onShutdown() { }
readonly shutdown: lib.AsyncHook<[]>;
/**
* Called when an event on a host connection happens
@@ -174,7 +165,8 @@ export default class BaseControllerPlugin {
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
// eslint-disable-next-line max-len
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when an avent on a control connection happens
@@ -210,7 +202,8 @@ export default class BaseControllerPlugin {
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
// eslint-disable-next-line max-len
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when a host is preparing to disconnect from the controller
@@ -224,7 +217,7 @@ export default class BaseControllerPlugin {
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareHostDisconnect(connection: HostConnection) { }
readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
/**
* Called when one or more mod packs are updated
@@ -237,7 +230,7 @@ export default class BaseControllerPlugin {
*
* @param modPacks - Mod packs that updated.
*/
async onModPacksUpdated(modPacks: ModPack[]) { }
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
/**
* Called when one or more mod stored on the controller are updated
@@ -249,7 +242,18 @@ export default class BaseControllerPlugin {
*
* @param mods - Mods that updated.
*/
async onModsUpdated(mods: ModInfo[]) { }
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
/**
* Called when one or more roles stored on the controller are updated
*
* Invoked when one or more roles have been added, updated or deleted.
*
* If a role has been deleted its `.isDeleted` property will be true.
*
* @param roles - Roles that updated.
*/
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
/**
* Called when a player joins or leaves an instance
@@ -261,7 +265,79 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseControllerPlugin {
onSaveData?(): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
}
/**
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseControllerPlugin {
constructor(
public info: lib.PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(controller.hooks.save, this.onSaveData);
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
attach(controller.hooks.metrics, this.onMetrics);
attach(controller.hooks.shutdown, this.onShutdown);
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
attach(controller.hooks.modsUpdated, this.onModsUpdated);
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
attach(controller.hooks.playerEvent, this.onPlayerEvent);
}
static fromContext(context: ControllerPluginContext) {
return new this(context.plugin, context.controller, context.metrics, context.logger);
}
detachHooks() {
this.controller.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
/**
* Broadcast event to all connected hosts
@@ -272,7 +348,7 @@ export default class BaseControllerPlugin {
*
* @param event - Event to send
*/
broadcastEventToHosts<T>(event: Event<T>) {
broadcastEventToHosts<T>(event: lib.Event<T>) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
+113 -33
View File
@@ -1,15 +1,15 @@
import type WsServerConnector from "./WsServerConnector";
import type WsServerConnector from "./WsServerConnector.js";
import events from "events";
import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import { logFilter, logger } from "@clusterio/lib";
import BaseConnection from "./BaseConnection";
import User from "./User";
import UserRecord from "./UserRecord";
import * as routes from "./routes";
import Controller from "./Controller";
import BaseConnection from "./BaseConnection.js";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import * as routes from "./routes.js";
import Controller from "./Controller.js";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -48,12 +48,16 @@ export default class ControlConnection extends BaseConnection {
this._version = registerData.version;
this.connector.on("connect", () => {
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
// A resume installs a fresh socket, so re-apply the ignore flag or the
// dumper's own frames get echoed back into it and recurse forever.
for (let event of ["connect", "resume"] as const) {
this.connector.on(event, () => {
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
}
this.connector.on("close", () => {
if (this.logTransport) {
logger.remove(this.logTransport);
this._controller.clusterLogger.remove(this.logTransport);
this.logTransport = null;
}
if (this.ws_dumper) {
@@ -63,14 +67,15 @@ export default class ControlConnection extends BaseConnection {
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
for (let controllerPlugin of this._controller.plugins.values()) {
controllerPlugin.onControlConnectionEvent(this, event);
}
this._controller.hooks.controlConnectionEvent.invoke(this, event);
});
}
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(
lib.ControllerMarkRestartRequiredRequest, this.handleControllerMarkRestartRequiredRequest.bind(this)
);
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
@@ -128,6 +133,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -169,7 +175,7 @@ export default class ControlConnection extends BaseConnection {
}
if (typeof permission === "string") {
this.user.checkPermission(permission);
this.user.checkPermission(permission as lib.PermissionName);
return;
}
@@ -189,6 +195,15 @@ 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.");
}
const downgrade = await this._controller.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
throw new lib.RequestError(
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
`is older than running version ${runningVersion}. ` +
"Stop the controller before starting the older version manually."
);
}
this._controller.shouldRestart = true;
this._controller.stop();
}
@@ -280,6 +295,9 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
if ((request.config["instance.assigned_host"] ?? null) !== null) {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
const instanceConfig = new lib.InstanceConfig("controller");
if (request.cloneFromId) {
const baseInstance = this._controller.instances.get(request.cloneFromId);
@@ -330,6 +348,7 @@ export default class ControlConnection extends BaseConnection {
}
instance.config.commitStaging();
await this._controller.instanceConfigUpdated(instance);
} finally {
instance.config.revertStaging();
}
@@ -674,6 +693,7 @@ export default class ControlConnection extends BaseConnection {
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
@@ -702,11 +722,16 @@ export default class ControlConnection extends BaseConnection {
return null;
}
// Select the latest matching version
// Select the latest matching version for the mod pack's Factorio version
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.filter(info => (
versionRange.testVersion(info.version)
&& lib.normaliseMajorMinorVersion(
lib.normaliseSourceVersion(info.info_json.factorio_version)
) === factorioVersion
))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
), undefined);
if (!release) {
@@ -738,16 +763,32 @@ export default class ControlConnection extends BaseConnection {
continue;
}
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
// Get the current version range (dependencies can be promoted to a stricter requirement)
const versionRange =
dependencyRequirements.get(mod.name)
?? recommendedRequirements.get(mod.name)
?? optionalRequirements.get(mod.name)
?? new lib.ModVersionRange();
if (mod.required) {
// Add to required, remove from recommended and optional
dependencyRequirements.set(mod.name, versionRange);
recommendedRequirements.delete(mod.name);
optionalRequirements.delete(mod.name);
} else if (mod.recommended) {
// If not required, add to recommended and remove from optional
if (!dependencyRequirements.has(mod.name)) {
recommendedRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
} else if (
!dependencyRequirements.has(mod.name)
&& !recommendedRequirements.has(mod.name)
) {
// If not required or recommended, add to optional
optionalRequirements.set(mod.name, versionRange);
}
// Update the version range if needed
@@ -756,6 +797,7 @@ export default class ControlConnection extends BaseConnection {
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
continue;
}
}
@@ -769,7 +811,11 @@ export default class ControlConnection extends BaseConnection {
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.filter(info => (
info.name === mod.name
&& info.factorioVersion === factorioVersion
&& versionRange.testVersion(info.version)
))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
@@ -803,9 +849,9 @@ export default class ControlConnection extends BaseConnection {
}
}
// Remove incompatible dependencies if they aren't required by others
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
errors.delete(modName);
}
}
@@ -829,6 +875,16 @@ export default class ControlConnection extends BaseConnection {
this.updateLogSubscriptions();
}
/** Stop following logs if the user no longer has permission to */
checkLogPermission() {
try {
this.user.checkPermission("core.log.follow");
} catch {
this.logSubscriptions = { all: false, controller: false, hostIds: [], instanceIds: [] };
this.updateLogSubscriptions();
}
}
updateLogSubscriptions() {
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
if (all || controller || hostIds.length || instanceIds.length) {
@@ -1034,6 +1090,11 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.users.deleteUser(user);
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.id === user.id) {
controlConnection.connector.terminate();
}
}
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -1249,6 +1310,10 @@ export default class ControlConnection extends BaseConnection {
return usersToSend;
}
async handleControllerMarkRestartRequiredRequest() {
this._controller.config.restartRequired = true;
}
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
@@ -1267,18 +1332,35 @@ 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");
}
return await lib.handlePluginInstall(request.pluginPackage);
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)) {
this._controller.config.restartRequired = true;
}
}
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
if (!this._controller.config.get("controller.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.loadedPlugins.has(pluginInfo),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
if (this.ws_dumper) {
return; // Already dumping
}
this.ws_dumper = data => {
if (this.connector.connected) {
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
@@ -1288,5 +1370,3 @@ export default class ControlConnection extends BaseConnection {
this._controller.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
+188 -80
View File
@@ -1,6 +1,7 @@
import type winston from "winston";
import { BlockList, type AddressInfo, isIPv4, isIPv6 } from "net";
import type { ControllerArgs } from "../controller";
import type { ControllerArgs } from "../controller.js";
import type ControlConnection from "./ControlConnection.js";
import express, { type Request, type Response, type NextFunction, type Application } from "express";
import type { Static } from "@sinclair/typebox";
import finalhandler from "finalhandler";
@@ -12,23 +13,25 @@ import http from "http";
import https from "https";
import jwt from "jsonwebtoken";
import path from "path";
import semver from "semver";
import stream from "stream";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as lib from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import { logger, Summary, Gauge } from "@clusterio/lib";
import HttpCloser from "./HttpCloser";
import InstanceRecord from "./InstanceRecord";
import * as metrics from "./metrics";
import * as routes from "./routes";
import User from "./User";
import UserRecord from "./UserRecord";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostRecord from "./HostRecord";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
import HttpCloser from "./HttpCloser.js";
import InstanceRecord from "./InstanceRecord.js";
import * as metrics from "./metrics.js";
import * as routes from "./routes.js";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import UserManager from "./UserManager.js";
import WsServer from "./WsServer.js";
import HostRecord from "./HostRecord.js";
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
import ControllerRouter from "./ControllerRouter.js";
import InstanceManager from "./InstanceManager.js";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -68,8 +71,10 @@ export default class Controller {
httpsServer: https.Server | null = null;
httpsServerCloser: HttpCloser | null = null;
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseControllerPlugin> = new Map();
/** Hooks which plugins can attach to */
hooks = new ControllerHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** WebSocket server */
wsServer: WsServer;
@@ -79,7 +84,7 @@ export default class Controller {
private _events = new events.EventEmitter<ControllerEvents>();
/** Event subscription controller */
subscriptions = new lib.SubscriptionController();
subscriptions: lib.SubscriptionController;
/** User manager for the cluster, also contains access to the user records */
users: UserManager;
/** Instance manager for the cluster, also contains access to the user records */
@@ -121,6 +126,28 @@ export default class Controller {
}
});
// Cache for the latest stable/experimental factorio releases. Persisted to
// disk so that release channel targets can still be resolved when
// factorio.com is unavailable.
latestReleases = new lib.ValueCache(async () => {
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
try {
const releases = await lib.fetchLatestReleases();
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
return releases;
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
try {
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
} catch (readErr: any) {
if (readErr.code !== "ENOENT") {
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
}
return {};
}
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.mkdir(databaseDirectory, { recursive: true });
@@ -247,6 +274,7 @@ export default class Controller {
});
// Handle subscriptions for all internal properties
this.subscriptions = new lib.SubscriptionController(config.get("controller.max_subscription_filters"));
this.subscriptions.handle(lib.SystemInfoUpdateEvent, this.handleSystemInfoSubscription.bind(this));
this.subscriptions.handle(lib.HostUpdatesEvent, this.handleHostSubscription.bind(this));
this.subscriptions.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsSubscription.bind(this));
@@ -322,7 +350,7 @@ export default class Controller {
// Start webpack development server if enabled
if (args.dev || args.devPlugin) {
this._startDevServer(args);
await this._startDevServer(args);
}
this.config.on("fieldChanged", (field, curr, prev) => {
@@ -332,8 +360,10 @@ export default class Controller {
this.onSystemMetricsIntervalChanged();
} else if (field === "controller.trusted_proxies") {
this.trustedProxies = this.parseTrustedProxies();
} else if (field === "controller.static_url") {
this.app.locals.staticRoot = this.config.get("controller.static_url");
}
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
this.hooks.controllerConfigFieldChanged.invoke(field, curr, prev);
});
// Make sure we're actually going to listen on a port
@@ -358,7 +388,7 @@ export default class Controller {
Controller.addAppRoutes(this.app, this.pluginInfos);
if (!args.dev) {
let manifestPath = path.join(__dirname, "..", "..", "web", "manifest.json");
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
let manifest = await Controller.loadJsonObject(manifestPath);
if (!manifest["main.js"]) {
@@ -366,6 +396,7 @@ export default class Controller {
}
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
}
this.app.locals.staticRoot = this.config.get("controller.static_url");
// Load plugins
await this.loadPlugins();
@@ -380,7 +411,7 @@ export default class Controller {
method: req.method,
url: req.originalUrl,
httpVersion: req.httpVersion,
headers: req.headers,
headers: Controller.loggableHeaders(req.headers),
query: req.query,
},
});
@@ -432,12 +463,13 @@ export default class Controller {
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpack = (await import("webpack")).default;
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
const webpackConfigs = [];
if (args.dev) {
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
// @ts-expect-error this file is run from a different folder
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
}
if (args.devPlugin) {
let devPlugins = new Map();
@@ -446,7 +478,10 @@ export default class Controller {
if (!info) {
throw new lib.StartupError(`No plugin named ${name}`);
}
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
let config = (await import(
path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
)).default({});
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
}
@@ -511,7 +546,7 @@ export default class Controller {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
}
await lib.invokeHook(this.plugins, "onShutdown");
await this.hooks.shutdown.invoke();
await this.wsServer.stop();
@@ -601,7 +636,9 @@ export default class Controller {
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJsonPath = fileURLToPath(
import.meta.resolve("@clusterio/controller/package.json"),
);
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
@@ -618,20 +655,44 @@ export default class Controller {
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
logger.warn(`Failed to read package.json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("controller.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/controller/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) {
logger.warn(
`Unable to compare running controller version ${runningVersion} ` +
`with installed version ${installedVersion}.`
);
return null;
}
if (semver.lt(installedVersion, runningVersion)) {
return { installedVersion, runningVersion };
}
} catch (err: any) {
logger.warn(`Failed to check controller version before restart:\n${err.stack ?? err.message}`);
}
return null;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -675,9 +736,8 @@ export default class Controller {
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
this.hooks.save.invoke(),
]);
await lib.invokeHook(this.plugins, "onSaveData");
}
static migrateSystems(rawJson: unknown[]): Static<typeof lib.SystemInfo.jsonSchema>[] {
@@ -741,8 +801,11 @@ export default class Controller {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
// All other versions are kept verbatim so they still match the mod's file name.
if (lib.isPartialVersion(mod.version)) {
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
}
}
return json;
});
@@ -781,7 +844,7 @@ export default class Controller {
try {
manifest = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
} catch (err: any) {
if (!throwOnMissing && err.code !== "ENOENT") {
if (throwOnMissing || err.code !== "ENOENT") {
throw err;
}
}
@@ -815,7 +878,7 @@ export default class Controller {
);
}
static addAppRoutes(app: Application, pluginInfos: any[]) {
static addAppRoutes(app: Application, pluginInfos: lib.PluginNodeEnvInfo[]) {
app.use((req: Request, res: Response, next) => {
const startNs = process.hrtime.bigint();
stream.finished(res, () => {
@@ -836,7 +899,7 @@ export default class Controller {
statusCode: res.statusCode,
responseTime: durationMs,
httpVersion: req.httpVersion,
headers: req.headers,
headers: Controller.loggableHeaders(req.headers),
query: req.query,
},
});
@@ -848,7 +911,7 @@ export default class Controller {
// Set folder to serve static content from (the website)
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
app.use("/static",
express.static(path.join(__dirname, "..", "..", "web", "static"), staticOptions)
express.static(path.join(import.meta.dirname, "..", "..", "web", "static"), staticOptions)
);
app.use("/static", express.static("static", staticOptions)); // Used for data export files
@@ -864,9 +927,7 @@ export default class Controller {
app.get(route, Controller.serveWeb(route));
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web", "static");
app.use("/static", express.static(webPath, staticOptions));
app.use("/static", express.static(pluginInfo.webStaticPath, staticOptions));
}
}
@@ -875,6 +936,9 @@ export default class Controller {
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
);
@@ -897,6 +961,9 @@ export default class Controller {
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const hosts = [...this.hosts.values()].filter(
host => host.updatedAtMs > request.lastRequestTimeMs,
).map(host => host.toHostDetails());
@@ -944,6 +1011,9 @@ export default class Controller {
}
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const instances = [...this.instances.values()].filter(
instance => instance.updatedAtMs > request.lastRequestTimeMs,
).map(instance => instance.toInstanceDetails());
@@ -955,17 +1025,25 @@ export default class Controller {
}
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
if (request.action === "unsubscribe") {
return null;
}
const saves = [...this.saves.values()].filter(
save => save.updatedAtMs > request.lastRequestTimeMs
);
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
}
modPacksUpdated(modPacks: lib.ModPack[]) {
this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks));
lib.invokeHook(this.plugins, "onModPacksUpdated", modPacks);
this.hooks.modPacksUpdated.invoke(modPacks);
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const modPacks = [...this.modPacks.values()].filter(
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
);
@@ -975,10 +1053,13 @@ export default class Controller {
modsUpdated(mods: lib.ModInfo[]) {
// ModStore sets updatedAtMs for mods
this.subscriptions.broadcast(new lib.ModUpdatesEvent(mods));
lib.invokeHook(this.plugins, "onModsUpdated", mods);
this.hooks.modsUpdated.invoke(mods);
}
async handleModSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const mods = [...this.modStore.files.values()].filter(
mod => mod.updatedAtMs > request.lastRequestTimeMs,
);
@@ -990,6 +1071,9 @@ export default class Controller {
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1004,39 +1088,41 @@ export default class Controller {
userPermissionsUpdated(user: Readonly<User>) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
new lib.AccountUpdateEvent([...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})))
);
this._controlPermissionsUpdated(controlConnection);
}
}
}
_controlPermissionsUpdated(controlConnection: ControlConnection) {
const user = controlConnection.user;
this.subscriptions.checkLinkPermissions(controlConnection, user);
controlConnection.checkLogPermission();
controlConnection.send(
new lib.AccountUpdateEvent([...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})))
);
}
rolesUpdated(roles: lib.Role[]) {
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
// lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
this.hooks.rolesUpdated.invoke(roles);
// Notify connected control clients with the given role that the permissions may have changed.
for (const role of roles) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user.roles.has(role)) {
controlConnection.send(
new lib.AccountUpdateEvent(
[...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
}))
)
);
this._controlPermissionsUpdated(controlConnection);
}
}
}
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1044,12 +1130,18 @@ export default class Controller {
}
async loadPlugins() {
const context = { metrics, logger, controller: this };
for (let pluginInfo of this.pluginInfos) {
try {
let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
pluginInfo.manifest = await Controller.loadJsonObject(require.resolve(manifestPath), true);
const manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
const resolvedUrl = import.meta.resolve(manifestPath);
const resolvedPath = resolvedUrl.startsWith("file://") ? fileURLToPath(resolvedUrl) : resolvedUrl;
pluginInfo.manifest = await Controller.loadJsonObject(resolvedPath, true);
} catch (err) {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
if (lib.pluginNeedsWebBuild(pluginInfo)) {
const stack = (err as Error).stack ?? (err as Error).message;
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}\n${stack}`);
}
}
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
@@ -1061,20 +1153,16 @@ export default class Controller {
continue;
}
let ControllerPluginClass = BaseControllerPlugin;
try {
if (pluginInfo.controllerEntrypoint) {
ControllerPluginClass = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.controllerEntrypoint),
"ControllerPlugin",
BaseControllerPlugin,
);
}
await lib.loadPlugin(
pluginInfo,
"controller",
context,
"ControllerPlugin",
BaseControllerPlugin,
);
let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics as any, logger);
await controllerPlugin.init();
this.plugins.set(pluginInfo.name, controllerPlugin);
this.loadedPlugins.add(pluginInfo);
} catch (err: any) {
if (err.code === "InstallationError") {
@@ -1140,7 +1228,23 @@ export default class Controller {
}
/**
* Servers the web interface with the root path set apropriately
* Copy of request headers with credentials masked for logging
*
* @param headers - headers of the incoming request.
* @returns headers safe to write to the log.
*/
static loggableHeaders(headers: http.IncomingHttpHeaders) {
const result = { ...headers };
for (const name of ["authorization", "cookie", "proxy-authorization", "x-access-token"]) {
if (result[name] !== undefined) {
result[name] = "[redacted]";
}
}
return result;
}
/**
* Serves the web interface with the root path set appropriately
*
* @param route - route the interface is served under.
* @returns Experess.js route handler.
@@ -1154,16 +1258,20 @@ export default class Controller {
return function(req: Request, res: Response, next: NextFunction) {
let depth = routeDepth + Number(req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
let staticRoot = webRoot;
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
let mainBundle: string = "";
if (res.app.locals.mainBundle) {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
mainBundle = stats.toJson().assetsByChunkName["main"];
mainBundle = stats.toJson().assetsByChunkName["main"][0];
}
mainBundle = routes.stripStaticPrefix(mainBundle);
fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => {
fs.readFile(
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
"utf8",
).then((content) => {
res.type("text/html");
res.send(content
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
+5 -5
View File
@@ -1,10 +1,10 @@
import type Controller from "./Controller";
import type BaseConnection from "./BaseConnection";
import type Controller from "./Controller.js";
import type BaseConnection from "./BaseConnection.js";
import * as lib from "@clusterio/lib";
import HostConnection from "./HostConnection";
import ControlConnection from "./ControlConnection";
const { logger } = lib;
import { logger } from "@clusterio/lib";
import HostConnection from "./HostConnection.js";
import ControlConnection from "./ControlConnection.js";
export default class ControllerRouter {
constructor(
+78 -25
View File
@@ -1,14 +1,17 @@
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import { logger } from "@clusterio/lib";
import BaseConnection from "./BaseConnection";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
import BaseConnection from "./BaseConnection.js";
import InstanceRecord from "./InstanceRecord.js";
import HostRecord from "./HostRecord.js";
// How long log message warnings are suppressed for after one is logged.
const logMessageWarnIntervalMs = 5 * 60 * 1000;
/**
* Represents the connection to a host
*
@@ -18,6 +21,8 @@ import HostRecord from "./HostRecord";
export default class HostConnection extends BaseConnection {
private info: HostRecord;
plugins: Map<string, string>;
// Shared by all log message warnings, a misbehaving host could log in a loop.
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
constructor(
registerData: lib.RegisterHostData,
@@ -47,12 +52,7 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
}
});
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
}
this.connector.on("close", () => {
@@ -66,7 +66,7 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
this._controller.instances.records.setMany(instances);
@@ -143,7 +143,7 @@ export default class HostConnection extends BaseConnection {
}
async prepareDisconnect() {
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
await this._controller.hooks.prepareHostDisconnect.invoke(this);
return await super.prepareDisconnect();
}
@@ -216,7 +216,7 @@ export default class HostConnection extends BaseConnection {
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
@@ -256,9 +256,7 @@ export default class HostConnection extends BaseConnection {
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
}
continue;
}
@@ -274,7 +272,7 @@ export default class HostConnection extends BaseConnection {
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
@@ -302,7 +300,37 @@ export default class HostConnection extends BaseConnection {
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
}
// A host may only report on instances assigned to it. Otherwise any host
// could speak for an instance on a different host. See
// handleInstanceStatusChangedEvent.
private _assignedInstance(instanceId: number) {
const instance = this._controller.instances.get(instanceId);
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
}
// Host connections live for days, so the suppression expires instead of
// latching for the life of the connection.
private _warnLogMessage(message: string) {
const now = Date.now();
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
return;
}
this._lastLogMessageWarnMs = now;
logger.warn(message);
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
// Otherwise any host could inject save records for, or wipe the save
// list of, an instance on a different host.
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
return;
}
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
logger.warn(`Got bogus save updates from host id ${this.id}`);
return;
}
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
@@ -327,8 +355,33 @@ export default class HostConnection extends BaseConnection {
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
// Fields beyond level and message survive schema validation, so the
// instance a log line claims to come from is whatever the host sent.
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
if (info.instance_id !== undefined) {
const instance = this._assignedInstance(info.instance_id);
if (!instance) {
// Demote to a host level line instead of dropping it. The host did
// log this, it just does not get to pin it on someone else's
// instance, and a host racing a reassignment still gets heard.
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
delete info.instance_id;
delete info.instance_name;
} else {
// The name the host sent may be stale or made up, ours is authoritative.
const name = instance.config.get("instance.name");
if (info.instance_name !== name) {
this._warnLogMessage(
`Got log message for instance id ${info.instance_id} named ` +
`${info.instance_name} which is named ${name} on the controller`
);
info.instance_name = name;
}
}
}
this._controller.clusterLogger.log({
...event.info,
...info,
host_id: this.id,
host_name: this.info.name,
});
@@ -350,7 +403,7 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.type,
name: event.name,
reason: event.reason,
@@ -367,7 +420,7 @@ export default class HostConnection extends BaseConnection {
user.set("isAdmin", event.admin);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
@@ -385,11 +438,11 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
resaon: event.reason,
reason: event.reason,
});
}
@@ -402,7 +455,7 @@ export default class HostConnection extends BaseConnection {
user.set("isWhitelisted", event.whitelisted);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
+16 -20
View File
@@ -1,8 +1,8 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
import type Controller from "./Controller.js";
import type HostConnection from "./HostConnection.js";
import InstanceRecord from "./InstanceRecord.js";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
@@ -83,12 +83,7 @@ export default class InstanceManager {
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
private async _notifyConfigFieldChanged(
@@ -97,14 +92,7 @@ export default class InstanceManager {
curr: unknown,
prev: unknown,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
}
/**
@@ -220,9 +208,17 @@ export default class InstanceManager {
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
try {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} catch (err) {
// Leave unassigned so the assign can be retried
instance.config.set("instance.assigned_host", null);
instance.status = "unassigned";
this.records.set(instance);
throw err;
}
} else {
instance.status = "unassigned";
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
PermissionError, PermissionName, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord";
import UserRecord from "./UserRecord.js";
import { Static } from "@sinclair/typebox";
/**
@@ -126,7 +126,7 @@ export default class User extends UserRecord implements IUser {
this.saveRecord();
}
checkPermission(permission: string) {
checkPermission(permission: PermissionName) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
+5 -3
View File
@@ -3,8 +3,8 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import User from "./User";
import UserRecord from "./UserRecord";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
/**
* Access layer for users on the controller.
@@ -119,7 +119,9 @@ export default class UserManager {
roles.add(defaultRoleId);
}
const user = new User(this.records, this._controllerRoles, 0, name, roles);
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
const tokenValidAfter = Math.floor(Date.now() / 1000);
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
this.records.set(user);
return user;
}
+16 -14
View File
@@ -1,18 +1,18 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type Controller from "./Controller";
import type Controller from "./Controller.js";
import jwt from "jsonwebtoken";
import WebSocket, { WebSocketServer } from "ws";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import { logger } from "@clusterio/lib";
import ControlConnection from "./ControlConnection";
import HostConnection from "./HostConnection";
import WsServerConnector from "./WsServerConnector";
import ControlConnection from "./ControlConnection.js";
import HostConnection from "./HostConnection.js";
import WsServerConnector from "./WsServerConnector.js";
import { version as packageVersion } from "../package.json";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
const wsMessageCounter = new lib.Counter(
"clusterio_controller_websocket_message_total",
@@ -45,12 +45,12 @@ export default class WsServer {
sessionAud: string = `session-${Date.now()}`;
nextSessionId: number = 1;
nextControlId: number = 1;
wss: WebSocket.Server;
wss: WebSocketServer;
constructor(controller: Controller) {
this.controller = controller;
this.wss = new WebSocket.Server({
this.wss = new WebSocketServer({
noServer: true,
path: "/api/socket",
});
@@ -135,11 +135,11 @@ export default class WsServer {
// Start connection handshake.
let loadedPlugins: {[key:string]: string} = {};
for (let [name, plugin] of this.controller.plugins) {
loadedPlugins[name] = plugin.info.version;
for (const plugin of this.controller.loadedPlugins) {
loadedPlugins[plugin.name] = plugin.version;
}
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
this.attachHandler(socket, req);
}
@@ -226,7 +226,8 @@ ${err.stack}`
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(sessionId);
@@ -287,10 +288,11 @@ ${err.stack}`
logger.info(
`WsServer | registered host ${data.id} using version ${data.version}`
);
if (data.version !== packageVersion) {
if (data.version !== packageConfig.version) {
logger.warn(
`Host ${data.id} connected using version ${data.version} which does not match the ` +
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
`version of the controller is currently running (${packageConfig.version}). ` +
"It may not work as expected."
);
}
+11 -6
View File
@@ -1,10 +1,10 @@
import type WebSocket from "ws";
import { type WebSocket } from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import { logger } from "@clusterio/lib";
/**
@@ -21,8 +21,10 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
private _sessionId: number,
private _sessionTimeout: number,
public _heartbeatInterval: number,
maxSendBufferSize = 0,
) {
super(new lib.Address(lib.Address.controller, 0), dst);
this.maxSendBufferSize = maxSendBufferSize;
// The following states are used in the server connector
// closed: Connection is closed
@@ -49,7 +51,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param account - account data to provide to control connection
*/
ready(
socket: WebSocket.WebSocket,
socket: WebSocket,
src: lib.Address,
sessionToken: string,
account: lib.AccountDetails | undefined
@@ -80,7 +82,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param lastSeq - The last message the client received.
*/
continue(
socket: WebSocket.WebSocket,
socket: WebSocket,
lastSeq: number
) {
@@ -106,14 +108,17 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
logger.verbose(
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
);
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
this._socket.send(message.text);
}
this.emit("resume");
}
_timedOut() {
logger.verbose("Connector | Connection timed out");
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
this._close();
}
+71 -20
View File
@@ -1,5 +1,5 @@
import type { Application, Request, Response } from "express";
import Controller from "./Controller";
import Controller from "./Controller.js";
import busboy from "busboy";
import crypto from "crypto";
@@ -12,7 +12,7 @@ import nodeStream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import { logger } from "@clusterio/lib";
const finished = util.promisify(nodeStream.finished);
@@ -22,8 +22,10 @@ declare global {
export interface Locals {
controller: Controller,
mainBundle: string,
staticRoot: string | null,
devPlugins: Map<string, number>,
streams: Map<string, ProxyStream>
streams: Map<string, ProxyStream>,
pendingMetrics?: Promise<string>,
}
}
}
@@ -43,12 +45,10 @@ function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceRe
}
}
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response, next: any) {
const controller: Controller = req.app.locals.controller;
// Collect metrics from the controller, its plugins and all connected hosts
async function gatherMetrics(controller: Controller) {
let results: lib.CollectorResult[] = [];
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
let pluginResults = await controller.hooks.metrics.collect();
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -98,18 +98,46 @@ async function getMetrics(req: Request, res: Response, next: any) {
results.push(lib.deserializeResult(result));
}
let text = await lib.exposition(results);
return await lib.exposition(results);
}
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response) {
try {
res.locals.user.checkPermission("core.controller.metrics");
} catch (err: any) {
res.status(403).json({ request_errors: [err.message] });
return;
}
// Concurrent scrapes share one gather so hosts see at most one
// metrics request at a time regardless of how many clients poll.
const locals = req.app.locals;
if (!locals.pendingMetrics) {
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
locals.pendingMetrics = undefined;
});
}
const text = await locals.pendingMetrics;
res.set("Content-Type", lib.expositionContentType);
res.send(text);
}
/**
* Removes the static/ prefix webpack adds to bundle paths so they can be
* resolved relative to staticRoot.
*/
export function stripStaticPrefix(bundlePath: string) {
return bundlePath.replace(/^static\//, "");
}
function getPlugins(req: Request, res: Response) {
let plugins: lib.PluginWebApi[] = [];
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
@@ -117,18 +145,21 @@ function getPlugins(req: Request, res: Response) {
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
web.main = stats.toJson().assetsByChunkName[name];
web.main = stats.toJson().assetsByChunkName[name][0];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else {
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
if (web.main) {
web.main = stripStaticPrefix(web.main);
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
}
res.send(plugins);
@@ -140,8 +171,21 @@ function getClusterName(req: Request, res: Response) {
res.json({ name: clusterName });
}
// Reads the access token from X-Access-Token or Authorization: Bearer
function getAccessToken(req: Request) {
const token = req.header("x-access-token");
if (token) {
return token;
}
const authorization = req.header("authorization");
if (authorization && /^Bearer /i.test(authorization)) {
return authorization.slice("Bearer ".length).trim();
}
return undefined;
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
let token = getAccessToken(req);
if (!token) {
res.sendStatus(401);
return;
@@ -168,7 +212,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
function validateUserToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
let token = getAccessToken(req);
if (!token) {
res.sendStatus(401);
return;
@@ -305,7 +349,7 @@ export async function createProxyStream(app: Application): Promise<ProxyStream>
return stream;
}
async function putStream(req: Request, res: Response) {
async function putStream(req: Request<{ id: string }>, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
@@ -344,7 +388,7 @@ function startStream(res: Response, stream: ProxyStream) {
clearTimeout(stream.timeout);
}
async function getStream(req: Request, res: Response) {
async function getStream(req: Request<{ id: string }>, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
@@ -641,15 +685,22 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/metrics",
validateUserToken,
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
);
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
);
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
app.put("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
);
app.get("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => getStream(req, res).catch(next)
);
app.post("/api/upload-save",
validateUserToken,
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
+1 -1
View File
@@ -29,7 +29,7 @@
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
<script>
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
function scriptError(err) {
console.error(err);
let bootstrapMsg = document.getElementById("bootstrap-msg");
File renamed without changes.
+29 -109
View File
@@ -1,21 +1,19 @@
#!/usr/bin/env node
"use strict";
const child_process = require("child_process");
const fs = require("node:fs/promises");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
import child_process from "node:child_process";
import fs from "node:fs/promises";
import inquirer from "inquirer";
import os from "node:os";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import yargs from "yargs";
const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
import { levels, logger, setLogLevel } from "./logging.js";
import { copyPluginTemplates } from "./template.js";
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
import { escapeArg } from "./escape_arg.js";
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
@@ -119,12 +117,11 @@ async function safeOutputFile(file, data, options={}) {
}
async function execFile(cmd, args) {
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
const command = [cmd, ...args.map(escapeArg)].join(" ");
logger.verbose(`executing ${command}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(
cmd,
escaped,
command,
{
shell: true,
@@ -149,21 +146,21 @@ async function execFile(cmd, args) {
async function execController(args) {
if (dev) {
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
}
async function execHost(args) {
if (dev) {
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
@@ -237,7 +234,7 @@ async function migrateRename(args) {
if (await pathExists(source) && !await pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
));
), { mode: 0o600 });
logger.info(`Migrated ${source} to ${destination}`);
}
}
@@ -410,7 +407,7 @@ async function installClusterio(mode, plugins) {
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
let pluginInfo = (await import(plugin)).plugin;
pluginList.set(pluginInfo.name, plugin);
}
}
@@ -660,22 +657,6 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -767,56 +748,8 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(tmpDir, { recursive: true });
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.mkdir(tmpFactorioDir, { recursive: true });
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.mkdir("factorio", { recursive: true });
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function main() {
let args = yargs
let yarg = yargs(process.argv.slice(2))
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
@@ -837,10 +770,6 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -875,20 +804,14 @@ async function main() {
;
if (process.platform === "linux") {
args = args
yarg = yarg
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
args = args.argv;
const args = yarg.parse();
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
@@ -911,13 +834,6 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
@@ -925,6 +841,7 @@ async function main() {
let adminToken = null;
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["config", "create"]);
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]);
@@ -935,6 +852,7 @@ async function main() {
if (answers.mode === "standalone") {
logger.info("Setting up host");
await execHost(["config", "create"]);
await execHost(["config", "set", "host.name", "local"]);
let result = await execHost(["config", "show", "host.id"]);
@@ -955,6 +873,7 @@ async function main() {
if (answers.mode === "host") {
logger.info("Setting up host");
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
await execHost(["config", "create"]);
await execHost(["config", "set", "host.id", hostId]);
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
@@ -971,8 +890,9 @@ async function main() {
}
if (answers.mode === "ctl") {
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
await execCtl(["config", "create"]);
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
}
/* eslint-disable no-console */
@@ -983,7 +903,7 @@ async function main() {
/* eslint-enable no-console */
}
if (module === require.main) {
if (import.meta.main) {
main().catch(err => {
if (err instanceof InstallError) {
logger.error(err.message);
+1 -7
View File
@@ -1,6 +1,4 @@
"use strict";
function escapeArg(arg) {
export function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
@@ -56,7 +54,3 @@ function escapeArg(arg) {
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+7 -12
View File
@@ -1,8 +1,7 @@
/* eslint-disable no-console */
"use strict";
const chalk = require("chalk");
import chalk from "chalk";
const levels = Object.freeze({
export const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
@@ -11,7 +10,7 @@ const levels = Object.freeze({
verbose: 6,
});
const colors = {
export const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
@@ -21,7 +20,7 @@ const colors = {
};
let maxLogLevel = levels.info;
const logger = {};
export const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
@@ -42,10 +41,6 @@ for (let [name, level] of Object.entries(levels)) {
};
}
module.exports = {
colors,
levels,
setLogLevel: (level) => { maxLogLevel = level; },
logger,
};
export function setLogLevel(level) {
maxLogLevel = level;
}
+25 -12
View File
@@ -1,26 +1,39 @@
{
"name": "@clusterio/create",
"version": "2.0.0-alpha.27",
"description": "Installer for clusterio",
"author": "Hornwitser",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"license": "MIT",
"version": "2.0.0-alpha.24",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=20"
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"dependencies": {
"chalk": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"chalk": "catalog:",
"inquirer": "^8.2.4",
"yargs": "catalog:"
}
}
+12 -14
View File
@@ -1,9 +1,8 @@
"use strict";
const { logger } = require("./logging");
const fs = require("node:fs/promises");
const path = require("path");
import { logger } from "./logging.js";
import fs from "node:fs/promises";
import path from "node:path";
const _package = require("./package.json");
import _package from "./package.json" with { type: "json" };
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -62,7 +61,7 @@ async function copyTemplateFile(src, dst, properties) {
});
// Create folder for output file
const dir = path.parse(dst);
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
@@ -79,7 +78,7 @@ async function copyTemplateFile(src, dst, properties) {
}
}
async function copyPluginTemplates(pluginName, templates) {
export async function copyPluginTemplates(pluginName, templates) {
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
const files = new Map();
const prepare = [];
@@ -96,8 +95,11 @@ async function copyPluginTemplates(pluginName, templates) {
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
const commonPath = path.resolve(__dirname, "./templates/common");
const templatePath = path.resolve(
import.meta.dirname,
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
);
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
@@ -119,7 +121,7 @@ async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
@@ -202,7 +204,3 @@ async function copyPluginTemplates(pluginName, templates) {
await Promise.all(writes);
logger.info("Successfully wrote all template files");
}
module.exports = {
copyPluginTemplates,
};
+33 -10
View File
@@ -2,6 +2,7 @@
"name": "__plugin_name__",
"version": "0.1.0",
"description": "Example Description. Package. Change me in package.json",
"type": "module",
//%if typescript
"main": "dist/node/index.js",
//%endif
@@ -18,9 +19,6 @@
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
@@ -29,24 +27,49 @@
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
//%if controller | host | ctl
"peerDependenciesMeta": {
//%endif
//%if controller
"@clusterio/controller": {
"optional": true
//%endif
//%if controller & host | controller & ctl
},
//%endif
//%if host
"@clusterio/host": {
"optional": true
//%endif
//%if host & ctl
},
//%endif
//%if ctl
"@clusterio/ctl": {
"optional": true
//%endif
//%if controller | host | ctl
}
},
//%endif
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
"typescript": "^5.9.3",
"@types/node": "^24.13.4",
//%endif
//%if typescript & web
"@types/react": "^18.2.21",
"@types/react": "^18.3.31",
//%endif
//%// Everything else
//%if web
"antd": "^5.13.0",
"antd": "^5.29.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if webpack
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack": "^5.110.3",
"webpack-cli": "^7.2.3",
"webpack-merge": "^5.9.0",
//%endif
//%if controller
@@ -64,7 +87,7 @@
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
"@sinclair/typebox": "^0.30.6"
},
"publishConfig": {
"access": "public"
@@ -1,85 +1,66 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
import * as lib from "@clusterio/lib";
//%if multi_context // Messages requires multi context
const {
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} = require("./messages");
} from "./messages.js";
//%endif
class ControllerPlugin extends BaseControllerPlugin {
export default async function loadControllerPlugin(context) {
const { controller, logger, plugin } = context;
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Messages requires multi context
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance, event) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
controller.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
}
});
//%endif
}
module.exports = {
ControllerPlugin,
};
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
});
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
+7 -13
View File
@@ -1,8 +1,6 @@
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -13,20 +11,16 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args, control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args, ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
export default async function loadCtlPlugin(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(pluginCommands);
}
});
}
module.exports = {
CtlPlugin,
};
+18 -29
View File
@@ -1,41 +1,30 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
import * as lib from "@clusterio/lib";
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
export default async function loadHostPlugin(context) {
const { host, logger, plugin } = context;
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
host.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
}
module.exports = {
HostPlugin,
};
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
+47 -47
View File
@@ -1,42 +1,9 @@
"use strict";
const lib = require("@clusterio/lib");
import * as lib from "@clusterio/lib";
//%if multi_context
const Messages = require("./messages");
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
import * as Messages from "./messages.js";
//%endif
const plugin = {
export const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
@@ -44,7 +11,7 @@ const plugin = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
controllerEntrypoint: "./dist/node/controller.js",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -60,7 +27,7 @@ const plugin = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
hostEntrypoint: "./dist/node/host.js",
//%endif
//%if host & config
hostConfigFields: {
@@ -76,7 +43,7 @@ const plugin = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
instanceEntrypoint: "./dist/node/instance.js",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -92,12 +59,12 @@ const plugin = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
ctlEntrypoint: "./dist/node/ctl.js",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -116,6 +83,43 @@ const plugin = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -124,7 +128,3 @@ const plugin = {
],
//%endif
};
module.exports = {
plugin,
};
+41 -59
View File
@@ -1,74 +1,56 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
import * as lib from "@clusterio/lib";
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field, curr, prev) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
export default async function loadInstancePlugin(context) {
const { instance, logger, plugin } = context;
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
instance.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
//%if module
async handlePluginExampleIPC(event) {
this.logger.info(JSON.stringify(event));
}
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
logger.info(JSON.stringify(event));
});
//%endif
}
module.exports = {
InstancePlugin,
};
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
@@ -1,11 +1,3 @@
"use strict";
const { BaseInstancePlugin } = require("@clusterio/host");
class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
module.exports = {
InstancePlugin,
};
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context) { }
@@ -1,8 +1,7 @@
"use strict";
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
const { Type } = require("@sinclair/typebox");
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
import { Type } from "@sinclair/typebox";
class PluginExampleEvent {
export class PluginExampleEvent {
static type = "event";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -24,7 +23,7 @@ class PluginExampleEvent {
}
}
class PluginExampleRequest {
export class PluginExampleRequest {
static type = "request";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -52,7 +51,7 @@ class PluginExampleRequest {
}
//%if controller & web // Subscribing requires web content and the controller
class ExampleSubscribableValue {
export class ExampleSubscribableValue {
constructor(id, updatedAtMs, isDeleted) {
this.id = id;
this.updatedAtMs = updatedAtMs;
@@ -70,7 +69,7 @@ class ExampleSubscribableValue {
}
}
class ExampleSubscribableUpdate {
export class ExampleSubscribableUpdate {
static type = "event";
static src = "controller";
static dst = "control";
@@ -90,12 +89,3 @@ class ExampleSubscribableUpdate {
}
}
//%endif
module.exports = {
PluginExampleEvent,
PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableValue,
ExampleSubscribableUpdate,
//%endif
};
@@ -10,7 +10,7 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,16 +21,26 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages";
} from "../messages.js";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
const [subscribableData, synced] = useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -41,53 +51,39 @@ function MyTemplatePage() {
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
export default async function loadWebPlugin(context) {
const { control, logger, plugin } = context;
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: "__plugin_name__.page.view",
//%endif
content: <MyTemplatePage/>,
},
];
content: <MyTemplatePage/>,
},
]);
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller// Subscribing requires web content and the controller
control.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
control.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
}
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -9,72 +9,61 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages";
} from "./messages.js";
//%endif
export class ControllerPlugin extends BaseControllerPlugin {
export default async function loadControllerPlugin(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase!: Map<string, ExampleSubscribableValue>;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Messages requires multi context
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request: lib.SubscriptionRequest) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
}
});
//%endif
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
}
);
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
+7 -7
View File
@@ -1,7 +1,7 @@
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -12,16 +12,16 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args: { foo: string }, control: Control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args: { foo: string }, ctl: Ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand: CommandTree) {
export default async function loadCtlPlugin(context: CtlPluginContext) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(pluginCommands);
}
});
}
+18 -24
View File
@@ -1,36 +1,30 @@
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
import type { HostPluginContext } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
export class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
export default async function loadHostPlugin(context: HostPluginContext) {
const { host, logger, plugin } = context;
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
+65 -44
View File
@@ -1,40 +1,8 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
import * as Messages from "./messages.js";
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
//%if config | multi_context | web
declare module "@clusterio/lib" {
//%endif
@@ -54,11 +22,27 @@ declare module "@clusterio/lib" {
}
//%endif
//%if ctl & config
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
export interface CtlConfigFields {
"__plugin_name__.myCtlField": string;
}
//%endif
//%if config
//%if multi_context | web
export interface Permissions {
//%endif
//%if multi_context
"__plugin_name__.example.permission.event": never;
"__plugin_name__.example.permission.request": never;
//%endif
//%if controller & web // Subscribing requires web content and the controller
"__plugin_name__.example.permission.subscribe": never;
//%endif
//%if web
"__plugin_name__.page.view": never;
//%endif
//%if multi_context | web
}
//%endif
//%if config | multi_context | web
}
//%endif
@@ -71,7 +55,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
controllerEntrypoint: "./dist/node/controller.js",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -87,7 +71,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
hostEntrypoint: "./dist/node/host.js",
//%endif
//%if host & config
hostConfigFields: {
@@ -103,7 +87,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
instanceEntrypoint: "./dist/node/instance.js",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -119,12 +103,12 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
ctlEntrypoint: "./dist/node/ctl.js",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -143,6 +127,43 @@ export const plugin: lib.PluginDeclaration = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
+42 -54
View File
@@ -1,76 +1,64 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
//%if module
type PuginExampleIPC = {
type PluginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
export class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%endif
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
export default async function loadInstancePlugin(context: InstancePluginContext) {
const { instance, logger, plugin } = context;
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
//%if module
async handlePluginExampleIPC(event: PuginExampleIPC) {
this.logger.info(JSON.stringify(event));
}
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
logger.info(JSON.stringify(event));
});
//%endif
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
@@ -1,7 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import type { InstancePluginContext } from "@clusterio/host";
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context: InstancePluginContext) { }
@@ -9,5 +9,5 @@
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
@@ -10,7 +10,8 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
type WebPluginContext,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,16 +22,26 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages";
} from "../messages.js";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber: lib.MapSubscriber<ExampleSubscribableUpdate>;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
const [subscribableData, synced] = useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -41,53 +52,39 @@ function MyTemplatePage() {
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
export default async function loadWebPlugin(context: WebPluginContext) {
const { control, logger, plugin } = context;
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: "__plugin_name__.page.view",
//%endif
content: <MyTemplatePage/>,
},
];
content: <MyTemplatePage/>,
},
]);
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller // Subscribing requires web content and the controller
control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
});
//%endif
}
+1
View File
@@ -0,0 +1 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+31 -10
View File
@@ -17,9 +17,12 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for ctl.
Defaults to `config-control.json` and will be created if it does not exist.
Defaults to `config-ctl.json` and is required for operation.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -27,17 +30,28 @@ Configure plugins available to be loaded by ctl.
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusterioctl plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, installing the Subspace Storage plugin:
For example, adding a plugin checked out in the external_plugins directory:
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
npx clusterioctl plugin add ./external_plugins/my_plugin
#### `plugin remove <name>`
@@ -59,13 +73,20 @@ Since the `plugin-list.json` is shared between controller, host and ctl you usua
Lists the plugins set up to be available by name followed by path.
### `config-control`
### `config`
Manage the ctl configuration.
This allows setting the url and token to the controller to manage.
#### `config-control set <config-entry> [value]`
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
If value is not provided the entry is set to null.
@@ -74,12 +95,12 @@ If the config entry is of type object the value must be a valid JSON serializati
See docs/configuration.md in the main repositiory for the available configuration.
#### `config-control show <config-entry>`
#### `config show <config-entry>`
Shows the value for a single config entry.
#### `config-control list`
#### `config list`
Lists up all configuration entries with their currently configured values.
+71 -95
View File
@@ -7,24 +7,23 @@
import fs from "node:fs/promises";
import yargs, { type Argv } from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
import packageConfig from "@clusterio/ctl/package.json" with { type: "json" };
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
(global as any).lazySchemaCompilation = true;
import "./src/set_lazy_schema_compliation.js";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import * as commands from "./src/commands";
import BaseCtlPlugin from "./src/BaseCtlPlugin";
import * as commands from "./src/commands.js";
import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
/**
* Connector for control connection to controller
* @private
*/
class ControlConnector extends lib.WebSocketClientConnector {
class CtlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, token: string) {
@@ -38,7 +37,7 @@ class ControlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
version,
packageConfig.version,
)
)
);
@@ -46,27 +45,23 @@ class ControlConnector extends lib.WebSocketClientConnector {
}
/**
* Handles running the control
* Handles running ctl
*
* Connects to the controller over WebSocket and sends commands to it.
* @static
*/
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
export class Ctl extends lib.Link {
/** Ctl config used for connecting to the controller. */
config: lib.CtlConfig;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
ctlPlugins: Map<string, BaseCtlPlugin>
connector: CtlConnector,
ctlConfig: lib.CtlConfig,
) {
super(connector);
this.config = controlConfig;
this.plugins = ctlPlugins;
this.config = ctlConfig;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
@@ -97,7 +92,7 @@ export class Control extends lib.Link {
async shutdown() {
try {
await (this.connector as ControlConnector).disconnect();
await (this.connector as CtlConnector).disconnect();
} catch (err) {
if (!(err instanceof lib.SessionLost)) {
throw err;
@@ -106,27 +101,23 @@ export class Control extends lib.Link {
}
}
async function loadPlugins(pluginList: Map<string, string>) {
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let ctlPlugins = new Map<string, BaseCtlPlugin>();
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.ctlEntrypoint) {
continue;
}
let CtlPlugin = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
"CtlPlugin",
BaseCtlPlugin,
);
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
ctlPlugins.set(pluginInfo.name, ctlPlugin);
await ctlPlugin.init();
const context = { hooks, logger, plugin: pluginInfo };
await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
ctlPlugins.add(pluginInfo);
}
return ctlPlugins;
}
@@ -137,19 +128,21 @@ interface CtlArguments {
logLevel: keyof typeof levels,
config: string,
pluginList: string,
bypassLockFile: boolean,
createConfig: boolean,
}
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
ctlPlugins?: Map<string, BaseCtlPlugin>;
ctlHooks: CtlHooks;
rootCommands?: lib.CommandTree;
controlConfig?: lib.ControlConfig;
ctlConfig?: lib.CtlConfig;
}
export async function initialize(
argv: string | string[],
ctlPlugins?: Map<string, BaseCtlPlugin>,
ctlHooks: CtlHooks = new CtlHooks(logger),
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
@@ -175,10 +168,16 @@ export async function initialize(
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-control.json",
default: "config-ctl.json",
defaultDescription: "auto",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -187,7 +186,7 @@ export async function initialize(
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.command("config", "Manage ctl config", lib.configCommand)
.wrap(parser.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
@@ -208,63 +207,47 @@ export async function initialize(
lib.handleUnhandledErrors();
}
// Discover and load plugins. This check exists to allow tests to inject plugins.
if (!ctlPlugins || args._[0] === "plugin") {
// Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
logger.verbose(`Loading available plugins from ${args.pluginList}`);
const pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return { args, shouldRun: false };
return { args, ctlHooks, shouldRun: false };
}
logger.verbose("Loading Plugins");
ctlPlugins = await loadPlugins(pluginList);
await loadPlugins(pluginList, ctlHooks);
}
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
const rootCommands = await commands.registerCommands(ctlHooks, parser);
args = parser
.help()
.strict()
.parse(argv) as CtlArguments
;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
const [ctlConfig, ctlConfigLock] = await lib.loadConfigFromArgs(
args,
lib.CtlConfig,
"control",
);
if (args._.length === 0) {
parser.showHelp();
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
// Handle the config command before trying to connect.
if (args._[0] === "config") {
await lib.handleConfigCommand(args, ctlConfig, ctlConfigLock);
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: false };
}
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
@@ -277,31 +260,30 @@ export function selectTargetCommand(args: CtlArguments, rootCommands: lib.Comman
return targetCommand;
}
async function startControl() {
async function startCtl() {
const {
args,
shouldRun,
ctlPlugins,
rootCommands,
controlConfig,
ctlConfig,
} = await initialize(process.argv.slice(2));
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
if (!shouldRun || !rootCommands || !ctlConfig) {
return;
}
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
if (!ctlConfig.get("ctl.controller_url") || !ctlConfig.get("ctl.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
controlConfig.get("control.controller_token")!,
let controlConnector = new CtlConnector(
ctlConfig.get("ctl.controller_url")!,
ctlConfig.get("ctl.max_reconnect_delay"),
ctlConfig.get("ctl.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
let ctl = new Ctl(controlConnector, ctlConfig);
try {
await controlConnector.connect();
} catch (err) {
@@ -311,22 +293,16 @@ async function startControl() {
throw err;
}
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", ctl.shutdown.bind(ctl)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", ctl.shutdown.bind(ctl)));
try {
const targetCommand = selectTargetCommand(args, rootCommands);
await targetCommand.run(args, control);
await targetCommand.run(args, ctl);
} catch (err) {
control.keepOpen = false;
ctl.keepOpen = false;
if (err instanceof lib.CommandError) {
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
@@ -344,8 +320,8 @@ async function startControl() {
}
} finally {
if (!control.keepOpen) {
await control.shutdown();
if (!ctl.keepOpen) {
await ctl.shutdown();
}
}
}
@@ -359,16 +335,16 @@ I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
startCtl().catch(err => {
if (err.errors) {
logger.fatal(JSON.stringify(err.errors, null, "\t"));
}
if (!(err instanceof lib.StartupError)) {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
+---------------------------------------------------------------+
| Unexpected error occured while starting clussterioctl, please |
| report it to https://github.com/clusterio/clusterio/issues |
+---------------------------------------------------------------+
${err.stack}`
);
} else {
@@ -384,6 +360,6 @@ ${err.stack}`
});
}
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { bootstrap } from "./ctl";
export * from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
import { bootstrap } from "./ctl.js";
export * from "./ctl.js";
export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
+26 -15
View File
@@ -1,9 +1,28 @@
{
"name": "@clusterio/ctl",
"version": "2.0.0-alpha.27",
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"license": "MIT",
"version": "2.0.0-alpha.24",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -12,27 +31,19 @@
"bin": {
"clusterioctl": "./dist/node/ctl.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"set-blocking": "catalog:",
"winston": "catalog:",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
"yargs": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"@types/yargs": "catalog:",
"typescript": "catalog:"
}
}
+53 -31
View File
@@ -1,38 +1,18 @@
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
import * as lib from "@clusterio/lib";
export type CtlPluginContext = lib.PluginLoadContext<{
hooks: CtlHooks
}>;
/**
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
* Collection of clusterioctl plugin hooks
*/
export default class BaseCtlPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
export class CtlHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.addCommands = this.newHook();
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called to add commands to the command line interface.
*
@@ -43,5 +23,47 @@ export default class BaseCtlPlugin {
* @param rootCommand -
* Root of the clusterioctl command tree.
*/
async addCommands(rootCommand: CommandTree) { }
readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseCtlPlugin {
addCommands?(rootCommand: lib.CommandTree): Promise<void>;
}
/**
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseCtlPlugin {
constructor(
/**
* The plugin's own info module
*/
public info: lib.PluginNodeEnvInfo,
public logger: lib.Logger,
private hooks: CtlHooks,
) {
if (this.addCommands) {
hooks.addCommands.attach(info.name, this.addCommands.bind(this));
}
}
static fromContext(context: CtlPluginContext) {
return new this(context.plugin, context.logger, context.hooks);
}
detachHooks() {
this.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
}
+13 -15
View File
@@ -1,19 +1,19 @@
import type { Argv } from "yargs";
import * as lib from "@clusterio/lib";
import type BaseCtlPlugin from "./BaseCtlPlugin";
import { controllerCommands } from "./commands_controller";
import { hostCommands } from "./commands_host";
import { instanceCommands } from "./commands_instance";
import { modPackCommands } from "./commands_mod_pack";
import { modCommands } from "./commands_mod";
import { permissionCommands } from "./commands_permission";
import { roleCommands } from "./commands_role";
import { userCommands } from "./commands_user";
import { logCommands } from "./commands_log";
import { debugCommands } from "./commands_debug";
import { controllerCommands } from "./commands_controller.js";
import { hostCommands } from "./commands_host.js";
import { instanceCommands } from "./commands_instance.js";
import { modPackCommands } from "./commands_mod_pack.js";
import { modCommands } from "./commands_mod.js";
import { permissionCommands } from "./commands_permission.js";
import { roleCommands } from "./commands_role.js";
import { userCommands } from "./commands_user.js";
import { logCommands } from "./commands_log.js";
import { debugCommands } from "./commands_debug.js";
import type { CtlHooks } from "./BaseCtlPlugin.js";
export async function registerCommands(ctlPlugins: Map<string, BaseCtlPlugin>, yargs: Argv) {
export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(controllerCommands);
rootCommands.add(hostCommands);
@@ -26,9 +26,7 @@ export async function registerCommands(ctlPlugins: Map<string, BaseCtlPlugin>, y
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
for (let controlPlugin of ctlPlugins.values()) {
await controlPlugin.addCommands(rootCommands);
}
await ctlHooks.addCommands.invoke(rootCommands);
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
+39 -30
View File
@@ -5,34 +5,45 @@ import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
controllerCommands.add(new lib.Command({
definition: ["stop", "Stop controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerStopRequest());
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerStopRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["mark-restart-required", "Mark the controller as requiring a restart"],
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerMarkRestartRequiredRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
handler: async function(args: { restart: boolean, all: boolean }, ctl: Ctl) {
if (args.all) {
await ctl.send(new lib.UpdateAllRequest());
} else {
await ctl.send(new lib.ControllerUpdateRequest());
}
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
await ctl.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
@@ -45,8 +56,8 @@ const controllerConfigCommands = new lib.CommandTree({
controllerConfigCommands.add(new lib.Command({
definition: ["list", "List controller configuration"],
handler: async function(args: object, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
handler: async function(args: object, ctl: Ctl) {
let config = await ctl.send(new lib.ControllerConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -62,13 +73,13 @@ controllerConfigCommands.add(new lib.Command({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
handler: async function(args: { field: string, value?: string, stdin?: boolean }, ctl: Ctl) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
await ctl.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
},
}));
@@ -81,7 +92,7 @@ controllerConfigCommands.add(new lib.Command({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, ctl: Ctl) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -97,7 +108,7 @@ controllerConfigCommands.add(new lib.Command({
}
value = args.value;
}
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
await ctl.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
@@ -108,17 +119,15 @@ controllerConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { editor: string }, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
handler: async function(args: { editor: string }, ctl: Ctl) {
let config = await ctl.send(new lib.ControllerConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(
config, lib.ControllerConfig, {}
);
let allConfigElements = serializedConfigToString(config, lib.ControllerConfig);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -133,7 +142,7 @@ controllerConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
await ctl.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
@@ -162,8 +171,8 @@ const controllerPluginCommands = new lib.CommandTree({
});
controllerPluginCommands.add(new lib.Command({
definition: ["list", "List plugins on controller"],
handler: async function(args: object, control: Control) {
let url = new URL(control.config.get("control.controller_url")!);
handler: async function(args: object, ctl: Ctl) {
let url = new URL(ctl.config.get("ctl.controller_url")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
@@ -174,10 +183,10 @@ controllerPluginCommands.add(new lib.Command({
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));
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
await ctl.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
await ctl.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
@@ -188,10 +197,10 @@ controllerPluginCommands.add(new lib.Command({
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));
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
await ctl.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
await ctl.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
+24 -4
View File
@@ -1,11 +1,31 @@
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import type { Ctl } from "../ctl.js";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
handler: async function(args: object, control: Control) {
await control.send(new lib.DebugDumpWsRequest());
control.keepOpen = true;
handler: async function(args: object, ctl: Ctl) {
await ctl.send(new lib.DebugDumpWsRequest());
ctl.keepOpen = true;
},
}));
debugCommands.add(new lib.Command({
definition: [
"parse-exchange-string <exchange-string>",
"Parse and print the contents of an exchange string",
(yargs) => {
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
}],
handler: async function(args: { exchangeString: string, pretty: boolean }, ctl: Ctl) {
const result = lib.readMapExchangeString(args.exchangeString);
if (args.pretty) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result, null, 2));
} else {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result));
}
},
}));
+65 -60
View File
@@ -6,9 +6,9 @@ import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -17,9 +17,9 @@ hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
@@ -27,16 +27,26 @@ hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["mark-restart-required <host>", "Mark the given host as requiring a restart", (yargs) => {
yargs.positional("host", { describe: "Host to mark", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, control: Control) {
let hosts = await control.send(new lib.HostListRequest());
handler: async function(args: object, ctl: Ctl) {
let hosts = await ctl.send(new lib.HostListRequest());
print(asTable(hosts));
},
}));
@@ -45,9 +55,9 @@ hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, control: Control) {
handler: async function(args: { id?: number }, ctl: Ctl) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
@@ -56,9 +66,9 @@ hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
@@ -75,9 +85,9 @@ hostCommands.add(new lib.Command({
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
control: Control
ctl: Ctl
) {
let config = await control.send(
let config = await ctl.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
@@ -87,7 +97,7 @@ hostCommands.add(new lib.Command({
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.writeFile(args.output, content, { flag: "wx" });
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
@@ -101,12 +111,17 @@ hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
handler: async function(args: { host: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostUpdateRequest());
handler: async function(args: { host: string, restart: boolean, all: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
if (args.all) {
await ctl.sendTo({ hostId }, new lib.UpdateAllRequest());
} else {
await ctl.sendTo({ hostId }, new lib.HostUpdateRequest());
}
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
@@ -121,9 +136,9 @@ hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -142,15 +157,15 @@ hostConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
ctl: Ctl,
) {
let hostId = await lib.resolveHost(control, args.host);
let hostId = await lib.resolveHost(ctl, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
@@ -166,9 +181,9 @@ hostConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
ctl: Ctl
) {
let hostId = await lib.resolveHost(control, args.host);
let hostId = await lib.resolveHost(ctl, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -184,7 +199,7 @@ hostConfigCommands.add(new lib.Command({
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
await ctl.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
@@ -197,21 +212,16 @@ hostConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
handler: async function(args: { host: string, editor: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
Try "ctl host config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
let allConfigElements = serializedConfigToString(config, lib.HostConfig);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -226,19 +236,14 @@ hostConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
@@ -262,9 +267,9 @@ hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest());
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
}))));
@@ -276,11 +281,11 @@ hostPluginCommands.add(new lib.Command({
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));
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
@@ -292,11 +297,11 @@ hostPluginCommands.add(new lib.Command({
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));
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
+108 -108
View File
@@ -9,9 +9,9 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
@@ -21,8 +21,8 @@ export const instanceCommands = new lib.CommandTree({
});
instanceCommands.add(new lib.Command({
definition: [["list", "l"], "List instances known to the controller"],
handler: async function(args: object, control: Control) {
let list = await control.send(new lib.InstanceDetailsListRequest());
handler: async function(args: object, ctl: Ctl) {
let list = await ctl.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
},
}));
@@ -36,13 +36,13 @@ instanceCommands.add(new lib.Command({
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
await control.send(new lib.InstanceCreateRequest(
await ctl.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
@@ -58,9 +58,9 @@ instanceConfigCommands.add(new lib.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -79,15 +79,15 @@ instanceConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
control: Control,
ctl: Ctl,
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let instanceId = await lib.resolveInstance(ctl, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
await ctl.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
},
}));
@@ -103,9 +103,9 @@ instanceConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
ctl: Ctl
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let instanceId = await lib.resolveInstance(ctl, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -136,7 +136,7 @@ instanceConfigCommands.add(new lib.Command({
}
value = args.value;
}
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
await ctl.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
@@ -149,21 +149,16 @@ instanceConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { instance: string, editor: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
handler: async function(args: { instance: string, editor: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
Try "ctl instance config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
let allConfigElements = serializedConfigToString(config, lib.InstanceConfig);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -178,20 +173,15 @@ instanceConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
await ctl.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
@@ -211,10 +201,10 @@ instanceCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
}],
handler: async function(args: { instance: string, host?: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined;
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
handler: async function(args: { instance: string, host?: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let hostId = args.host ? await lib.resolveHost(ctl, args.host) : undefined;
await ctl.send(new lib.InstanceAssignRequest(instanceId, hostId));
},
}));
@@ -250,9 +240,9 @@ instanceSaveCommands.add(new lib.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let saves = await ctl.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
@@ -280,12 +270,12 @@ instanceSaveCommands.add(new lib.Command({
mapGenSettings?: string,
mapSettings?: string,
},
control: Control
ctl: Ctl
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let instanceId = await lib.resolveInstance(ctl, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
@@ -298,9 +288,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args: { instance: string, oldName: string, newName: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
handler: async function(args: { instance: string, oldName: string, newName: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
},
}));
@@ -310,9 +300,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args: { instance: string, source: string, destination: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
handler: async function(args: { instance: string, source: string, destination: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
},
}));
@@ -324,14 +314,14 @@ instanceSaveCommands.add(new lib.Command({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
handler: async function(args: { instance: string, filepath: string, name?: string }, ctl: Ctl) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Save name must end with .zip");
}
let instanceId = await lib.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.controller_url")!);
let instanceId = await lib.resolveInstance(ctl, args.instance);
let url = new URL(ctl.config.get("ctl.controller_url")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
@@ -341,7 +331,7 @@ instanceSaveCommands.add(new lib.Command({
body: (await fs.open(args.filepath)).createReadStream(),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
"Content-Type": "application/zip",
},
});
@@ -389,11 +379,11 @@ instanceSaveCommands.add(new lib.Command({
targetSave: string,
copy: boolean,
},
control: Control
ctl: Ctl
) {
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
let sourceInstanceId = await lib.resolveInstance(ctl, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(ctl, args.targetInstance);
let storedName = await ctl.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
@@ -411,11 +401,11 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let streamId = await ctl.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
let url = new URL(control.config.get("control.controller_url")!);
let url = new URL(ctl.config.get("ctl.controller_url")!);
url.pathname += `api/stream/${streamId}`;
const filename = await lib.downloadFile(url, args.save, "rename");
@@ -428,9 +418,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
},
}));
instanceCommands.add(instanceSaveCommands);
@@ -439,10 +429,20 @@ instanceCommands.add(new lib.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["save-game <instance>", "Save the running game of an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to save the game on", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.sendTo({ instanceId }, new lib.InstanceSaveGameRequest());
},
}));
@@ -450,10 +450,10 @@ instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
},
}));
@@ -465,11 +465,11 @@ instanceCommands.add(new lib.Command({
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
const instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
ctl.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
@@ -480,11 +480,11 @@ instanceCommands.add(new lib.Command({
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
control.keepOpen = args.keepOpen;
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
const instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
ctl.keepOpen = args.keepOpen;
},
}));
@@ -499,9 +499,9 @@ instanceCommands.add(new lib.Command({
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
handler: async function(args: { force: boolean }, ctl: Ctl) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
@@ -531,7 +531,7 @@ instanceCommands.add(new lib.Command({
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
@@ -565,15 +565,15 @@ instanceCommands.add(new lib.Command({
mapSettings?: string,
keepOpen: boolean,
},
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.sendTo(
await ctl.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
control.keepOpen = args.keepOpen;
ctl.keepOpen = args.keepOpen;
},
}));
@@ -581,18 +581,18 @@ instanceCommands.add(new lib.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStopRequest());
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceStopRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
handler: async function(_args, ctl: Ctl) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
@@ -611,7 +611,7 @@ instanceCommands.add(new lib.Command({
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
@@ -627,10 +627,10 @@ instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceKillRequest());
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceKillRequest());
},
}));
@@ -638,9 +638,9 @@ instanceCommands.add(new lib.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteRequest(instanceId));
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceDeleteRequest(instanceId));
},
}));
@@ -649,9 +649,9 @@ instanceCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args: { instance: string, command: string }, control: Control) {
let result = await control.sendTo(
{ instanceId: await lib.resolveInstance(control, args.instance) },
handler: async function(args: { instance: string, command: string }, ctl: Ctl) {
let result = await ctl.sendTo(
{ instanceId: await lib.resolveInstance(ctl, args.instance) },
new lib.InstanceSendRconRequest(args.command),
);
+10 -10
View File
@@ -2,7 +2,7 @@ import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import type { Ctl } from "../ctl.js";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
@@ -16,17 +16,17 @@ logCommands.add(new lib.Command({
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
control: Control
ctl: Ctl
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
control.keepOpen = true;
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
await ctl.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
ctl.keepOpen = true;
},
}));
@@ -52,16 +52,16 @@ logCommands.add(new lib.Command({
limit: number,
start: boolean,
},
control: Control
ctl: Ctl
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
let result = await control.send(new lib.LogQueryRequest(
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
let result = await ctl.send(new lib.LogQueryRequest(
args.all,
args.controller,
hostIds,
+24 -20
View File
@@ -7,8 +7,8 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
@@ -19,11 +19,11 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
let modInfo = await ctl.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
@@ -48,8 +48,8 @@ modCommands.add(new lib.Command({
},
});
}],
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
handler: async function(args: { fields: string[] }, ctl: Ctl) {
let mods = await ctl.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
@@ -106,11 +106,15 @@ modCommands.add(new lib.Command({
sortOrder: "asc" | "desc",
fields: string[],
},
control: Control
ctl: Ctl
) {
let response = await control.send(new lib.ModSearchRequest(
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
if (searchVersion === undefined) {
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
}
let response = await ctl.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
lib.normaliseMajorMinorVersion(searchVersion),
args.page,
args.pageSize,
args.sort,
@@ -139,13 +143,13 @@ modCommands.add(new lib.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args: { file: string }, control: Control) {
handler: async function(args: { file: string }, ctl: Ctl) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Mod filename must end with .zip");
}
let url = new URL(control.config.get("control.controller_url")!);
let url = new URL(ctl.config.get("ctl.controller_url")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
@@ -154,7 +158,7 @@ modCommands.add(new lib.Command({
body: (await fs.open(args.file)).createReadStream(),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
"Content-Type": "application/zip",
},
});
@@ -186,13 +190,13 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let streamId = await ctl.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
let url = new URL(ctl.config.get("ctl.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let filename = `${args.name}_${args.modVersion}.zip`;
await lib.downloadFile(url, filename, "overwrite");
@@ -206,10 +210,10 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
await ctl.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
+23 -23
View File
@@ -2,8 +2,8 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -12,12 +12,12 @@ modPackCommands.add(new lib.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
handler: async function(args: { modPack: string }, ctl: Ctl) {
let mods = new Map(
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
(await ctl.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
let modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
@@ -57,8 +57,8 @@ modPackCommands.add(new lib.Command({
modPackCommands.add(new lib.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args: object, control: Control) {
let modPacks = await control.send(new lib.ModPackListRequest());
handler: async function(args: object, ctl: Ctl) {
let modPacks = await ctl.send(new lib.ModPackListRequest());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
if (!lib.isSourceVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
@@ -179,7 +179,7 @@ modPackCommands.add(new lib.Command({
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
ctl: Ctl,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
@@ -193,7 +193,7 @@ modPackCommands.add(new lib.Command({
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await control.send(new lib.ModPackCreateRequest(modPack));
await ctl.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
@@ -202,9 +202,9 @@ modPackCommands.add(new lib.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args: { string: string }, control: Control) {
handler: async function(args: { string: string }, ctl: Ctl) {
const modPack = lib.ModPack.fromModPackString(args.string);
await control.send(new lib.ModPackCreateRequest(modPack));
await ctl.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
@@ -213,9 +213,9 @@ modPackCommands.add(new lib.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
handler: async function(args: { modPack: string }, ctl: Ctl) {
const modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
);
print(modPack.toModPackString());
},
@@ -257,10 +257,10 @@ modPackCommands.add(new lib.Command({
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
ctl: Ctl,
) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
const modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
);
if (args.name) { modPack.name = args.name; }
@@ -300,7 +300,7 @@ modPackCommands.add(new lib.Command({
}
}
}
await control.send(new lib.ModPackUpdateRequest(modPack));
await ctl.send(new lib.ModPackUpdateRequest(modPack));
},
}));
@@ -308,8 +308,8 @@ modPackCommands.add(new lib.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const id = await lib.resolveModPack(control, args.modPack);
await control.send(new lib.ModPackDeleteRequest(id));
handler: async function(args: { modPack: string }, ctl: Ctl) {
const id = await lib.resolveModPack(ctl, args.modPack);
await ctl.send(new lib.ModPackDeleteRequest(id));
},
}));
+4 -4
View File
@@ -1,16 +1,16 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new lib.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args: object, control: Control) {
let permissions = await control.send(new lib.PermissionListRequest());
handler: async function(args: object, ctl: Ctl) {
let permissions = await ctl.send(new lib.PermissionListRequest());
print(asTable(permissions));
},
}));
+13 -13
View File
@@ -2,16 +2,16 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
roleCommands.add(new lib.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args: object, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
handler: async function(args: object, ctl: Ctl) {
let roles = await ctl.send(new lib.RoleListRequest());
print(asTable(roles));
},
}));
@@ -24,8 +24,8 @@ roleCommands.add(new lib.Command({
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args: { name: string, description: string, permissions: string[] }, control: Control) {
let id = await control.send(new lib.RoleCreateRequest(
handler: async function(args: { name: string, description: string, permissions: string[] }, ctl: Ctl) {
let id = await ctl.send(new lib.RoleCreateRequest(
args.name,
args.description,
args.permissions,
@@ -58,9 +58,9 @@ roleCommands.add(new lib.Command({
removePerms?: string[],
grantDefault?: boolean,
},
control: Control
ctl: Ctl
) {
let role = await lib.retrieveRole(control, args.role);
let role = await lib.retrieveRole(ctl, args.role);
if (args.name !== undefined) {
role.name = args.name;
@@ -81,10 +81,10 @@ roleCommands.add(new lib.Command({
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
await ctl.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
await ctl.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
@@ -93,8 +93,8 @@ roleCommands.add(new lib.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args: { role: string }, control: Control) {
let role = await lib.retrieveRole(control, args.role);
await control.send(new lib.RoleDeleteRequest(role.id));
handler: async function(args: { role: string }, ctl: Ctl) {
let role = await lib.retrieveRole(ctl, args.role);
await ctl.send(new lib.RoleDeleteRequest(role.id));
},
}));
+30 -30
View File
@@ -3,8 +3,8 @@ import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -16,8 +16,8 @@ userCommands.add(new lib.Command({
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
handler: async function(args: { name: string, instanceStats: boolean }, ctl: Ctl) {
let user = await ctl.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
@@ -27,7 +27,7 @@ userCommands.add(new lib.Command({
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await control.send(new lib.InstanceDetailsListRequest());
let instances = await ctl.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
@@ -51,8 +51,8 @@ userCommands.add(new lib.Command({
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) {
let users = await control.send(new lib.UserListRequest());
handler: async function(args: { stats: boolean, attributes: boolean }, ctl: Ctl) {
let users = await ctl.send(new lib.UserListRequest());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
@@ -82,8 +82,8 @@ userCommands.add(new lib.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserCreateRequest(args.name));
handler: async function(args: { name: string }, ctl: Ctl) {
await ctl.send(new lib.UserCreateRequest(args.name));
},
}));
@@ -91,8 +91,8 @@ userCommands.add(new lib.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserRevokeTokenRequest(args.name));
handler: async function(args: { name: string }, ctl: Ctl) {
await ctl.send(new lib.UserRevokeTokenRequest(args.name));
},
}));
@@ -104,8 +104,8 @@ userCommands.add(new lib.Command({
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, revoke: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
handler: async function(args: { user: string, revoke: boolean, create: boolean }, ctl: Ctl) {
await ctl.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
},
}));
@@ -117,8 +117,8 @@ userCommands.add(new lib.Command({
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, remove: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
handler: async function(args: { user: string, remove: boolean, create: boolean }, ctl: Ctl) {
await ctl.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
},
}));
@@ -138,9 +138,9 @@ userCommands.add(new lib.Command({
reason: string,
create: boolean
},
control: Control
ctl: Ctl
) {
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
await ctl.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
@@ -149,8 +149,8 @@ userCommands.add(new lib.Command({
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args: { user: string, roles: string[] }, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
handler: async function(args: { user: string, roles: string[] }, ctl: Ctl) {
let roles = await ctl.send(new lib.RoleListRequest());
let resolvedRoles = [];
for (let roleName of args.roles) {
@@ -174,7 +174,7 @@ userCommands.add(new lib.Command({
}
}
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
await ctl.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
@@ -182,8 +182,8 @@ userCommands.add(new lib.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args: { user: string }, control: Control) {
await control.send(new lib.UserDeleteRequest(args.user));
handler: async function(args: { user: string }, ctl: Ctl) {
await ctl.send(new lib.UserDeleteRequest(args.user));
},
}));
@@ -194,7 +194,7 @@ async function handleImportOrRestore(args: {
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, restore: boolean) {
}, ctl: Ctl, restore: boolean) {
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
@@ -218,7 +218,7 @@ async function handleImportOrRestore(args: {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
const backup = await ctl.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
@@ -239,8 +239,8 @@ userCommands.add(new lib.Command({
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
handler: async function(args: any, ctl: any) {
await handleImportOrRestore(args, ctl, false);
},
}));
@@ -255,8 +255,8 @@ userCommands.add(new lib.Command({
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
handler: async function(args: any, ctl: any) {
await handleImportOrRestore(args, ctl, true);
},
}));
@@ -276,7 +276,7 @@ userCommands.add(new lib.Command({
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
}, ctl: Ctl) {
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
const optionCount = [args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
@@ -296,7 +296,7 @@ userCommands.add(new lib.Command({
exportType = "whitelist";
}
await fs.writeFile(args.filepath, JSON.stringify(await control.send(
await fs.writeFile(args.filepath, JSON.stringify(await ctl.send(
new lib.UserBulkExportRequest(exportType)
), undefined, "\t"));
print(`Exported ${exportType} to ${args.filepath}`);
+20 -23
View File
@@ -15,18 +15,14 @@ export async function configToKeyVal(data: string) {
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
let split = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = split[1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
// split on the first = only, values may contain = themselves
let split = filtered[index].indexOf("=");
if (split === -1) {
// no value given, it's a empty field and therefor null
final[filtered[index].trim()] = "";
continue;
}
final[finalIndex] = part;
final[filtered[index].slice(0, split).trim()] = filtered[index].slice(split + 1).trim();
}
}
return final;
@@ -34,25 +30,26 @@ export async function configToKeyVal(data: string) {
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
configClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
) {
const config = configClass.fromJSON({}, "control");
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
if (name in disallowedList) {
const def = (configClass.fieldDefinitions as lib.ConfigDefs<any>)[name];
try {
if (!config.canAccess(name, lib.ConfigAccess.readWrite) || def.hidden) {
continue;
}
} catch (err) {
// Field does not exist in the config class
continue;
}
let desc = "";
try {
desc += (configGroup.fieldDefinitions as any)[name]!.description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
if (value === null) {
value = "";
} else if (typeof value === "object") {
value = JSON.stringify(value);
}
allConfigElements += `${name} = ${value}\n\n`;
allConfigElements += `# ${def.description ?? "No description found"}\n${name} = ${value}\n\n`;
}
return allConfigElements;
}
@@ -0,0 +1,3 @@
// This needs to be a separate file due to ECMAScript Modules evaluating code in
// imported modules before any code in the file that imports it.
(global as any).lazySchemaCompilation = true;
+1
View File
@@ -0,0 +1 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+52 -6
View File
@@ -16,9 +16,12 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the host.
Defaults to `config-host.json` and will be created if it does not exist.
Defaults to `config-host.json` and is required for operation.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -26,17 +29,28 @@ Configure plugins available to be loaded by the host.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusteriohost plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, installing the Subspace Storage plugin:
For example, adding a plugin checked out in the external_plugins directory:
npm install @clusterio/plugin-subspace_storage
npx clusteriohost plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
npx clusteriohost plugin add ./external_plugins/my_plugin
#### `plugin remove <name>`
@@ -64,6 +78,13 @@ Manage the host configuration offline.
This should only be used when the host is stopped, otherwise the config read might be out of date and config changes will be overwritten when the host shuts down.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
@@ -78,6 +99,31 @@ See docs/configuration.md in the main repositiory for the available configuratio
Shows the value for a single config entry.
### `create-scenario <output>`
Create a scenario with the Clusterio modules patched into it and write it to `output`.
This is the same patching that is applied to saves when an instance starts, see docs/how-it-works.md in the main repository.
The result can be used as a regular scenario outside of Clusterio by copying it to the `scenarios` folder of Factorio or into a mod.
The IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected.
By default the freeplay scenario from the latest Factorio install in `host.factorio_directory` is patched with the modules from all plugins that are enabled in the host config.
* `--scenario <path>` Scenario directory, zipped scenario or save to patch instead of freeplay.
This has the same restrictions as save patching, the scenario must either use the event_handler library and have been seen by Clusterio before, or already have been patched by Clusterio.
* `--factorio-version <version>` Factorio version to take the freeplay scenario from.
Defaults to `latest`.
* `--plugins <name>...` Only patch in modules from the given plugins.
* `--zip` Write the scenario as a zip file instead of a directory.
Factorio does not load zipped scenarios, extract it before use.
For example, creating a scenario with only the inventory_sync module:
npx clusteriohost create-scenario ~/.factorio/scenarios/inventory_sync --plugins inventory_sync
#### `config list`
Lists up all configuration entries with their currently configured values.
+32 -55
View File
@@ -15,8 +15,7 @@
import fs from "node:fs/promises";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
import { version } from "./package.json";
import packageConfig from "@clusterio/host/package.json" with { type: "json" };
import winston from "winston";
import "winston-daily-rotate-file";
@@ -24,7 +23,8 @@ import "winston-daily-rotate-file";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host";
import Host from "./src/Host.js";
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
let host: Host | undefined;
@@ -70,7 +70,7 @@ export class HostConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.hostConfig.get("host.controller_token"),
version,
packageConfig.version,
this.hostConfig.get("host.id"),
plugins,
)
@@ -83,7 +83,7 @@ export class HostConnector extends lib.WebSocketClientConnector {
async function startHost() {
// argument parsing
// eslint-disable-next-line node/no-sync
const args = yargs
const args = yargs(process.argv.slice(2))
.scriptName("host")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -105,6 +105,12 @@ async function startHost() {
default: "config-host.json",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -114,6 +120,11 @@ async function startHost() {
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command(
"create-scenario <output>",
"Create a scenario patched with modules for use outside Clusterio",
createScenarioCommand,
)
.command("run", "Run host", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
@@ -146,7 +157,7 @@ async function startHost() {
let command = args._[0];
if (command === "run") {
logger.info(`Starting Clusterio host ${version}`);
logger.info(`Starting Clusterio host ${packageConfig.version}`);
if (args.recovery) {
logger.warn("Host recovery mode enabled. Some features will be disabled.");
}
@@ -164,37 +175,27 @@ async function startHost() {
logger.info("Loading Plugin info");
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
const [hostConfig, hostConfigLock] = await lib.loadConfigFromArgs(
args,
lib.HostConfig,
"host",
);
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
hostConfig = new lib.HostConfig("host", undefined, hostConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
hostConfig.set("host.version", version); // Allows tracking last loaded version
hostConfig.set("host.version", packageConfig.version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
if (command === "create-scenario") {
await handleCreateScenarioCommand(args, hostConfig, pluginInfos);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
@@ -239,32 +240,8 @@ async function startHost() {
);
// Handle interrupts
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
@@ -333,6 +310,6 @@ ${err.stack}`
});
}
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
+7 -7
View File
@@ -1,10 +1,10 @@
import { bootstrap } from "./host";
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
export { default as Host, HostRouter } from "./src/Host";
export { default as Instance } from "./src/Instance";
export { default as InstanceConnection } from "./src/InstanceConnection";
import { bootstrap } from "./host.js";
export { BaseHostPlugin, HostHooks, type HostPluginContext } from "./src/BaseHostPlugin.js";
export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from "./src/BaseInstancePlugin.js";
export { default as Host, HostRouter } from "./src/Host.js";
export { default as Instance } from "./src/Instance.js";
export { default as InstanceConnection } from "./src/InstanceConnection.js";
if (module === require.main) {
if (import.meta.main) {
bootstrap();
}
@@ -16,6 +16,10 @@
{
"version": "2.0.20",
"factorio_version": "2.0"
},
{
"version": "2.0.21",
"factorio_version": "2.1"
}
],
"title": "Clusterio Library (Alpha)",
+3
View File
@@ -1,3 +1,6 @@
--- Undefined fields are expected, the typedefs only cover the latest version
--- @diagnostic disable: undefined-field
local function send_json(channel, data)
data = helpers and helpers.table_to_json(data) or game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
+25 -2
View File
@@ -10,13 +10,16 @@ Implementation Guidelines
local lib_json = require("json")
--- @diagnostic disable: deprecated
--- Undefined fields are expected, the typedefs only cover the latest version
--- @diagnostic disable: deprecated, undefined-field
--- @class LibCompat
--- @field script_data table
--- @field prototypes LuaPrototypes
--- @field active_mods table<string, string>
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
--- @field encode_string fun(string: string): string
--- @field decode_string fun(string: string): string
--- @field table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
@@ -177,6 +180,24 @@ function runtime_properties.write_file()
end
end
--- Deflates and base64 encodes the given string.
function runtime_properties.encode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.encode_string
elseif v2 then
return helpers.encode_string
end
end
--- Base64 decodes and inflates the given string.
function runtime_properties.decode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.decode_string
elseif v2 then
return helpers.decode_string
end
end
--- Convert tables to and from json, we have backported this to work before runtime
if v0 or v1 then
compat.table_to_json = function(tbl)
@@ -218,4 +239,6 @@ set_script_data()
compat.on_init = set_script_data --- @package
compat.on_load = set_script_data --- @package
return setmetatable(compat, compat_mt)
-- Not returned directly because that would infer the metatable type rather than LibCompat
setmetatable(compat, compat_mt)
return compat
+3
View File
@@ -22,6 +22,9 @@
-- SOFTWARE.
--
--- Vendored file, the forward declared recursion is not worth annotating
--- @diagnostic disable: need-check-nil
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
+31 -18
View File
@@ -1,9 +1,28 @@
{
"name": "@clusterio/host",
"version": "2.0.0-alpha.27",
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"license": "MIT",
"version": "2.0.0-alpha.24",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -13,35 +32,29 @@
"bin": {
"clusteriohost": "./dist/node/host.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.0",
"jimp": "^1.6.1",
"jszip": "catalog:",
"pidusage": "^3.0.2",
"pidusage": "^4.0.1",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"semver": "catalog:",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
"yargs": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/pidusage": "^2.0.5",
"@types/semver": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"@types/yargs": "catalog:",
"typescript": "catalog:"
}
}
+77 -43
View File
@@ -1,45 +1,24 @@
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Host from "./Host";
import type Host from "./Host.js";
import * as lib from "@clusterio/lib";
export type HostPluginContext = lib.PluginLoadContext<{
host: Host;
}>;
/**
* Base class for host plugins
*
* Host plugins are subclasses of this class which get instantiated by
* the host when it starts up with the plugin enabled in the config. To be
* discovered the class must be exported under the name `HostPlugin` in the
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
* Collection of host plugin hooks
*/
export default class BaseHostPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Host the plugin started for
*/
public host: Host,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
export class HostHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.metrics = this.newHook();
this.shutdown = this.newHook();
this.hostConfigFieldChanged = this.newHook();
this.controllerConnectionEvent = this.newHook();
this.prepareControllerDisconnect = this.newHook();
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a host config field changed.
*
@@ -50,7 +29,7 @@ export default class BaseHostPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
readonly hostConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
/**
* Called before collecting Prometheus metrics
@@ -67,12 +46,12 @@ export default class BaseHostPlugin {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called when the host is shutting down
*/
async onShutdown() { }
*/
readonly shutdown: lib.AsyncHook<[]>;
/**
* Called when an event on the controller connection happens
@@ -108,7 +87,7 @@ export default class BaseHostPlugin {
*
* @param event - one of connect, drop, resume and close
*/
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when the controller is preparing to disconnect from the host
@@ -123,5 +102,60 @@ export default class BaseHostPlugin {
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareControllerDisconnect(connection: Host) { }
readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Host]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseHostPlugin {
onHostConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Host): Promise<void>;
}
/**
* Base class for host plugins
*
* Host plugins are subclasses of this class which get instantiated by
* the host when it starts up with the plugin enabled in the config. To be
* discovered the class must be exported under the name `HostPlugin` in the
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseHostPlugin {
constructor(
public info: lib.PluginNodeEnvInfo,
public host: Host,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(host.hooks.hostConfigFieldChanged, this.onHostConfigFieldChanged);
attach(host.hooks.metrics, this.onMetrics);
attach(host.hooks.shutdown, this.onShutdown);
attach(host.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
attach(host.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
}
static fromContext(context: HostPluginContext): BaseHostPlugin {
const plugin = new this(context.plugin, context.host, context.logger);
return plugin;
}
detachHooks() {
this.host.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
}
+120 -70
View File
@@ -1,69 +1,30 @@
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Instance from "./Instance";
import type Host from "./Host";
import type Instance from "./Instance.js";
import type Host from "./Host.js";
import * as lib from "@clusterio/lib";
export type InstancePluginContext = lib.PluginLoadContext<{
host: Host;
instance: Instance;
}>;
/**
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the host when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's `plugin` export.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
* Collection of instance plugin hooks
*/
export default class BaseInstancePlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
private _pendingRconMessages: {
resolve: (result: string) => void,
reject: (err: Error) => void,
message: string,
expectEmpty: boolean,
}[] = [];
private _sendingRconMessages = false;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Instance the plugin started for
*/
public instance: Instance,
/**
* Host running the instance
*
* With the exepction of accessing the host's config you should
* avoid ineracting with the host object directly.
*/
public host: Host,
) {
this.logger = instance.logger.child({ plugin: this.info.name }) as unknown as Logger;
this._pendingRconMessages = [];
this._sendingRconMessages = false;
export class InstanceHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.metrics = this.newHook();
this.start = this.newHook();
this.stop = this.newHook();
this.exit = this.newHook();
this.output = this.newHook();
this.instanceConfigFieldChanged = this.newHook();
this.controllerConnectionEvent = this.newHook();
this.prepareControllerDisconnect = this.newHook();
this.playerEvent = this.newHook();
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a config field changed.
*
@@ -74,7 +35,7 @@ export default class BaseInstancePlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
readonly instanceConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
/**
* Called before collecting Prometheus metrics
@@ -91,12 +52,12 @@ export default class BaseInstancePlugin {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
/**
* Called after the Factorio server is started
*/
async onStart() { }
readonly start: lib.AsyncHook<[]>;
/**
* Called before the Factorio server is stopped
@@ -104,7 +65,7 @@ export default class BaseInstancePlugin {
* This will not be called if for example the Factorio server crashes or
* is killed.
*/
async onStop() { }
readonly stop: lib.AsyncHook<[]>;
/**
* Called when the instance exits
@@ -113,7 +74,7 @@ export default class BaseInstancePlugin {
* has been called if an error occurs during startup. Note that if
* the plugin's init() throws this method will still be invoked.
*/
onExit() { }
readonly exit: lib.AsyncHook<[]>;
/**
* Called when the Factorio outputs a line
@@ -125,7 +86,7 @@ export default class BaseInstancePlugin {
* @param parsed - parsed server output.
* @param line - raw line of server output.
*/
async onOutput(parsed: ParsedFactorioOutput, line: string) { }
readonly output: lib.AsyncHook<[parsed: lib.ParsedFactorioOutput, line: string]>;
/**
* Called when an event on the controller connection happens
@@ -161,7 +122,7 @@ export default class BaseInstancePlugin {
*
* @param event - one of connect, drop, resume and close
*/
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when the controller is preparing to disconnect from the host
@@ -176,7 +137,7 @@ export default class BaseInstancePlugin {
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareControllerDisconnect(connection: Instance) { }
readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Instance]>;
/**
* Called when a player joins or leaves the game
@@ -185,7 +146,96 @@ export default class BaseInstancePlugin {
*
* @param event - Information about the event.
*/
async onPlayerEvent(event: PlayerEvent) { }
readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseInstancePlugin {
onInstanceConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onStart?(): Promise<void>;
onStop?(): Promise<void>;
onExit?(): void;
onOutput?(parsed: lib.ParsedFactorioOutput, line: string): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
onPlayerEvent?(event: lib.PlayerEvent): Promise<void>;
}
/**
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the host when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's `plugin` export.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseInstancePlugin {
private _pendingRconMessages: {
resolve: (result: string) => void,
reject: (err: Error) => void,
message: string,
expectEmpty: boolean,
}[] = [];
private _sendingRconMessages = false;
constructor(
/**
* The plugin's own info module
*/
public info: lib.PluginNodeEnvInfo,
/**
* Instance the plugin started for
*/
public instance: Instance,
/**
* Host running the instance
*
* With the exepction of accessing the host's config you should
* avoid ineracting with the host object directly.
*/
public host: Host,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(instance.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
attach(instance.hooks.metrics, this.onMetrics);
attach(instance.hooks.start, this.onStart);
attach(instance.hooks.stop, this.onStop);
attach(instance.hooks.exit, this.onExit);
attach(instance.hooks.output, this.onOutput);
attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
attach(instance.hooks.playerEvent, this.onPlayerEvent);
}
static fromContext(context: InstancePluginContext) {
return new this(context.plugin, context.instance, context.host, context.logger);
}
detachHooks() {
this.instance.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
/**
* Send RCON message to instance
+90 -38
View File
@@ -1,21 +1,20 @@
import fs from "node:fs/promises";
import path from "path";
import events from "events";
import pidusage from "pidusage";
import semver from "semver";
import setBlocking from "set-blocking";
import stream from "stream";
import { fileURLToPath } from "node:url";
import util from "util";
// internal libraries
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { HostConnector } from "../host";
import Instance from "./Instance";
import InstanceConnection from "./InstanceConnection";
import BaseHostPlugin from "./BaseHostPlugin";
const finished = util.promisify(stream.finished);
import type { HostConnector } from "../host.js";
import Instance from "./Instance.js";
import InstanceConnection from "./InstanceConnection.js";
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
function checkRequestSaveName(name: string) {
@@ -182,6 +181,12 @@ export class HostRouter {
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.host) {
// Sent up to the controller, which passes it on to the other
// hosts. Nothing left to do when it came from there.
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.control) {
if (this.host !== origin) {
if (!plugin || this.host.serverPlugins.has(plugin)) {
@@ -265,8 +270,10 @@ export default class Host extends lib.Link {
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseHostPlugin> = new Map();
/** Hooks which plugins can attach to */
hooks = new HostHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
@@ -325,7 +332,7 @@ export default class Host extends lib.Link {
if (name === "host.name" || name === "host.public_address") {
this.sendHostUpdate();
}
lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev);
this.hooks.hostConfigFieldChanged.invoke(name, curr, prev);
});
this.connector.on("hello", data => {
@@ -377,14 +384,13 @@ export default class Host extends lib.Link {
for (let instanceConnection of this.instanceConnections.values()) {
instanceConnection.send(message);
}
for (let plugin of this.plugins.values()) {
plugin.onControllerConnectionEvent(event);
}
this.hooks.controllerConnectionEvent.invoke(event);
});
}
this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this));
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostMarkRestartRequiredRequest, this.handleHostMarkRestartRequiredRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
@@ -396,6 +402,7 @@ export default class Host extends lib.Link {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
@@ -416,6 +423,7 @@ export default class Host extends lib.Link {
}
async loadPlugins() {
const context = { logger, host: this };
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
@@ -429,20 +437,16 @@ export default class Host extends lib.Link {
continue;
}
let HostPluginClass = BaseHostPlugin;
try {
if (pluginInfo.hostEntrypoint) {
HostPluginClass = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint),
"HostPlugin",
BaseHostPlugin,
);
}
await lib.loadPlugin(
pluginInfo,
"host",
context,
"HostPlugin",
BaseHostPlugin,
);
let hostPlugin = new HostPluginClass(pluginInfo, this, logger);
await hostPlugin.init();
this.plugins.set(pluginInfo.name, hostPlugin);
this.loadedPlugins.add(pluginInfo);
} catch (err: any) {
if (err.code === "InstallationError") {
@@ -496,10 +500,22 @@ 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.");
}
const downgrade = await this.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
throw new lib.RequestError(
`Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` +
`running version ${runningVersion}. Stop the host before starting the older version manually.`
);
}
process.exitCode = 1;
this.shutdown();
}
async handleHostMarkRestartRequiredRequest() {
this.config.restartRequired = true;
}
async handleHostConfigGetRequest() {
return this.config.toRemote("control");
}
@@ -659,10 +675,8 @@ export default class Host extends lib.Link {
return await this.downloadMod(mod);
}
async fetchMods(mods: Iterable<lib.ModRecord>) {
// This is better than the previous hard coded names
// But it really shouldn't be a hard coded version either
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
if (builtinModNames.includes(mod.name)) {
@@ -846,7 +860,7 @@ export default class Host extends lib.Link {
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
@@ -863,20 +877,44 @@ export default class Host extends lib.Link {
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
logger.warn(`Failed to read package.json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("host.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) {
logger.warn(
`Unable to compare running host version ${runningVersion} ` +
`with installed version ${installedVersion}.`
);
return null;
}
if (semver.lt(installedVersion, runningVersion)) {
return { installedVersion, runningVersion };
}
} catch (err: any) {
logger.warn(`Failed to check host version before restart:\n${err.stack ?? err.message}`);
}
return null;
}
async handleHostMetricsRequest() {
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
for (let instanceConnection of this.instanceConnections.values()) {
@@ -884,7 +922,7 @@ export default class Host extends lib.Link {
}
let results = [];
let pluginResults = await lib.invokeHook(this.plugins, "onMetrics");
let pluginResults = await this.hooks.metrics.collect();
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -1110,13 +1148,27 @@ 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");
}
return await lib.handlePluginInstall(request.pluginPackage);
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)) {
this.config.restartRequired = true;
}
}
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
if (!this.config.get("host.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.plugins.has(pluginInfo.name),
this.loadedPlugins.has(pluginInfo),
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1177,7 +1229,7 @@ export default class Host extends lib.Link {
}
async prepareDisconnect() {
await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this);
await this.hooks.prepareControllerDisconnect.invoke(this);
for (let instanceConnection of this.instanceConnections.values()) {
await instanceConnection.send(new lib.PrepareControllerDisconnectRequest());
}
@@ -1194,7 +1246,7 @@ export default class Host extends lib.Link {
}
this._shuttingDown = true;
await lib.invokeHook(this.plugins, "onShutdown");
await this.hooks.shutdown.invoke();
for (let instanceConnection of this.instanceConnections.values()) {
try {
+174 -80
View File
@@ -9,11 +9,20 @@ const execAsync = util.promisify(exec);
// internal libraries
import * as lib from "@clusterio/lib";
import { FactorioServer } from "./server";
import { SaveModule, patch } from "./patch";
import { exportData } from "./export";
import type Host from "./Host";
import BaseInstancePlugin from "./BaseInstancePlugin";
import { FactorioServer } from "./server.js";
import { loadModules, patch } from "./patch.js";
import { exportData } from "./export.js";
import type Host from "./Host.js";
import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js";
// Reject save names that would escape the instance's saves directory.
function checkRequestSaveName(name: string) {
try {
lib.checkFilename(name);
} catch (err: any) {
throw new lib.RequestError(`Save name ${err.message}`);
}
}
const scriptCommands = [
"/cheat", "/editor",
@@ -22,6 +31,11 @@ const scriptCommands = [
"/silent-command", "/sc",
];
// Statuses the instance passes through on its way to running or stopped.
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
"starting", "stopping", "creating_save", "exporting_data",
]);
const instanceRconCommandDuration = new lib.Histogram(
"clusterio_instance_rcon_command_duration_seconds",
"Histogram of the RCON command duration from request to response.",
@@ -61,7 +75,7 @@ function applyAsConfig(name: string) {
// Replace spaces with non-break spaces and delimit by spaces.
// This does change the defined tags, but there doesn't seem to
// be a way to include a space into a tag from the console.
value = value.map(tag => tag.replace(/ /g, "\u00a0")).join(" ");
value = value.map(tag => String(tag).replace(/ /g, "\u00a0")).join(" ");
}
try {
await instance.sendRcon(`/config set ${name} ${value}`);
@@ -140,7 +154,6 @@ export default class Instance extends lib.Link {
* ID of this instance, equivalenet to `instance.config.get("instance.id")`.
*/
readonly id: number;
plugins: Map<string, BaseInstancePlugin>;
config: lib.InstanceConfig;
logger: lib.Logger;
server: FactorioServer;
@@ -156,6 +169,10 @@ export default class Instance extends lib.Link {
* Players currently online on the instance.
*/
playersOnline: Set<string> = new Set();
/** Hooks which plugins can attach to */
hooks: InstanceHooks;
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
_host: Host;
@@ -167,6 +184,7 @@ export default class Instance extends lib.Link {
_hadPlayersOnline = false;
_playerAutosaveSlot = 1;
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
_settleWaiters: (() => void)[] = [];
constructor(
host: Host,
@@ -179,7 +197,6 @@ export default class Instance extends lib.Link {
this._host = host;
this._dir = dir;
this.plugins = new Map();
this.config = instanceConfig;
this.id = this.config.get("instance.id");
@@ -189,21 +206,21 @@ export default class Instance extends lib.Link {
instance_name: this.name,
});
this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => {
let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev);
this.hooks = new InstanceHooks(this.logger);
this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => {
if (field === "factorio.shutdown_timeout") {
this.server.shutdownTimeoutMs = curr as number * 1000;
} else if (field === "factorio.max_concurrent_commands") {
this.server.maxConcurrentCommands = curr as number;
} else if (field === "factorio.settings") {
this.updateFactorioSettings(curr as any, prev as any).finally(hook);
this._applyWhenRunning(
"server settings", () => this.updateFactorioSettings(curr as any, prev as any)
);
} else if (field === "factorio.enable_whitelist") {
this.updateFactorioWhitelist(curr as any).finally(hook);
} else {
if (field === "factorio.max_concurrent_commands") {
this.server.maxConcurrentCommands = curr as number;
}
hook();
this._applyWhenRunning("whitelist", () => this.updateFactorioWhitelist(curr as any));
}
this.hooks.instanceConfigFieldChanged.invoke(field, curr, prev);
};
this.config.on("fieldChanged", this._configFieldChanged);
@@ -231,7 +248,7 @@ export default class Instance extends lib.Link {
this.server.on("output", (parsed, line) => {
this.logger.log("server", { message: line, instance_id: this.id, parsed });
lib.invokeHook(this.plugins, "onOutput", parsed, line);
this.hooks.output.invoke(parsed, line);
});
this.server.on("error", err => {
@@ -288,6 +305,7 @@ export default class Instance extends lib.Link {
this.handle(lib.InstanceLoadScenarioRequest, this.handleInstanceLoadScenarioRequest.bind(this));
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
this.handle(lib.InstanceSaveGameRequest, this.handleInstanceSaveGameRequest.bind(this));
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
@@ -301,7 +319,12 @@ export default class Instance extends lib.Link {
return;
}
let name = /^([^ ]+)/.exec(parsed.message)![1];
// Guard against lines that classify as actions but lack a name
let nameMatch = /^([^ ]+)/.exec(parsed.message);
if (!nameMatch) {
return;
}
let name = nameMatch[1];
// Detect player leave and join
if (parsed.action === "JOIN") {
@@ -317,7 +340,9 @@ export default class Instance extends lib.Link {
// Detect banned and admin state change, whitelist changes are not logged
if (parsed.action === "BAN") {
const reason = /\. Reason: (.+)\.$/.exec(parsed.message)![1];
// Ban lines without a ". Reason: X." tail default to no reason
const reasonMatch = /\. Reason: (.+)\.$/.exec(parsed.message);
const reason = reasonMatch ? reasonMatch[1] : "";
this._recordUserUpdate(parsed.action, name, reason !== "unspecified" ? reason : "");
} else if (["UNBANNED", "PROMOTE", "DEMOTE"].includes(parsed.action)) {
this._recordUserUpdate(parsed.action as "UNBANNED" | "PROMOTE" | "DEMOTE", name);
@@ -343,8 +368,11 @@ export default class Instance extends lib.Link {
return;
}
const name = /^([^ ]+)/.exec(parsed.message)![1];
this._recordUserUpdate(parsed.action as "PROMOTE" | "DEMOTE", name);
const nameMatch = /^([^ ]+)/.exec(parsed.message);
if (!nameMatch) {
return;
}
this._recordUserUpdate(parsed.action as "PROMOTE" | "DEMOTE", nameMatch[1]);
});
}
@@ -397,7 +425,7 @@ export default class Instance extends lib.Link {
stats,
};
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
lib.invokeHook(this.plugins, "onPlayerEvent", event);
this.hooks.playerEvent.invoke(event);
}
_recordPlayerLeave(name: string, reason: string) {
@@ -418,7 +446,7 @@ export default class Instance extends lib.Link {
stats,
};
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason));
lib.invokeHook(this.plugins, "onPlayerEvent", event);
this.hooks.playerEvent.invoke(event);
}
async handleInstanceExtractPlayersRequest() {
@@ -449,7 +477,7 @@ end`.replace(/\r?\n/g, " ");
stats,
};
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, stats));
lib.invokeHook(this.plugins, "onPlayerEvent", event);
this.hooks.playerEvent.invoke(event);
count += 1;
}
this.logger.info(`Extracted data for ${count} player(s)`);
@@ -547,6 +575,13 @@ end`.replace(/\r?\n/g, " ");
notifyStatus(status: Instance["_status"]) {
this._status = status;
if (!transitoryStatuses.has(status)) {
const waiters = this._settleWaiters;
this._settleWaiters = [];
for (const resolve of waiters) {
resolve();
}
}
this.sendTo(
"controller",
new lib.InstanceStatusChangedEvent(
@@ -568,6 +603,26 @@ end`.replace(/\r?\n/g, " ");
return this._status;
}
/** Wait for a transitory status to settle and return the status settled on */
async _waitForSettledStatus() {
while (transitoryStatuses.has(this._status)) {
await new Promise<void>(resolve => this._settleWaiters.push(resolve));
}
return this._status;
}
/** Apply work to the running server, waiting out transitory statuses and skipping it if it stopped */
async _applyWhenRunning(description: string, fn: () => Promise<void>) {
if (await this._waitForSettledStatus() !== "running") {
return;
}
try {
await fn();
} catch (err: any) {
this.logger.error(`Error updating ${description}:\n${err.stack}`);
}
}
notifyExit() {
this._loadedSave = null;
this.notifyStatus("stopped");
@@ -587,9 +642,7 @@ end`.replace(/\r?\n/g, " ");
}
// Notify plugins of exit
for (let pluginInstance of this.plugins.values()) {
pluginInstance.onExit();
}
this.hooks.exit.invoke();
for (let player of this.playersOnline) {
this._recordPlayerLeave(player, "server_quit");
@@ -597,21 +650,6 @@ end`.replace(/\r?\n/g, " ");
this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`));
}
async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) {
let pluginLoadStartedMs = Date.now();
let InstancePluginClass = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint!),
"InstancePlugin",
BaseInstancePlugin,
);
let instancePlugin = new InstancePluginClass(pluginInfo, this, host);
this.plugins.set(pluginInfo.name, instancePlugin);
await instancePlugin.init();
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`);
}
async _loadStats() {
let instanceStats;
try {
@@ -642,6 +680,21 @@ end`.replace(/\r?\n/g, " ");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
// Resolve a release channel target (e.g. "stable") to a concrete
// version using the controller's cached latest-releases data, so the
// existing download/lookup logic can fetch and run it.
const targetVersion = this.config.get("factorio.version") as string;
if (lib.isReleaseChannel(targetVersion)) {
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
if (!resolved) {
throw new Error(
`Cannot resolve Factorio release channel '${targetVersion}': ` +
"latest-releases unavailable and no cached copy on the controller."
);
}
this.server.setTargetVersion(resolved);
}
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
@@ -651,6 +704,7 @@ end`.replace(/\r?\n/g, " ");
}
// load plugins
const plugins: Record<string, string> = {};
for (let pluginInfo of pluginInfos) {
if (
!pluginInfo.instanceEntrypoint
@@ -666,7 +720,18 @@ end`.replace(/\r?\n/g, " ");
}
try {
await this._loadPlugin(pluginInfo, this._host);
let pluginLoadStartedMs = Date.now();
await lib.loadPlugin(
pluginInfo,
"instance",
{ logger: this.logger, host: this._host, instance: this },
"InstancePlugin",
BaseInstancePlugin,
);
this.loadedPlugins.add(pluginInfo);
plugins[pluginInfo.name] = pluginInfo.version;
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`);
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
@@ -674,10 +739,6 @@ end`.replace(/\r?\n/g, " ");
}
}
let plugins: Record<string, string> = {};
for (let [name, plugin] of this.plugins) {
plugins[name] = plugin.info.version;
}
this.send(new lib.InstanceInitialisedEvent(plugins));
}
@@ -780,11 +841,10 @@ end`.replace(/\r?\n/g, " ");
} else {
modPack = await this.sendTo("controller", new lib.ModPackGetRequest(modPackId));
}
this.checkModPackVersion(modPack);
this.activeModPack = modPack;
// TODO validate factorioVersion
const mods = await this._host.fetchMods(modPack.mods.values());
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
await fs.mkdir(this.path("mods"), { recursive: true });
@@ -833,6 +893,25 @@ end`.replace(/\r?\n/g, " ");
await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
}
/**
* Check that the mod pack does not target a newer Factorio than the server
*
* The mod pack's Factorio version is written into mod-settings.dat and
* Factorio refuses to read it if it is newer than the game. It then
* loads default settings instead, which can corrupt the save.
*
* @throws {lib.RequestError} if the mod pack version is higher than the server version.
*/
checkModPackVersion(modPack: lib.ModPack) {
const serverVersion = this.server.version;
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
throw new lib.RequestError(
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
`which is newer than the Factorio ${serverVersion} this instance runs`
);
}
}
/**
* Prepare instance for starting
*
@@ -937,29 +1016,7 @@ end`.replace(/\r?\n/g, " ");
// Patch save with lua modules from plugins
this.logger.verbose("Patching save");
// Find plugin modules to patch in
let modules: Map<string, SaveModule> = new Map();
for (let plugin of this.plugins.values()) {
let module = await SaveModule.fromPlugin(plugin);
if (!module) {
continue;
}
modules.set(module.info.name, module);
}
// Find stand alone modules to load
// XXX for now only the included clusterio module is loaded
let modulesDirectory = path.join(__dirname, "..", "..", "..", "modules");
for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (modules.has(entry.name)) {
throw new Error(`Module with name ${entry.name} already exists in a plugin`);
}
let module = await SaveModule.fromDirectory(path.join(modulesDirectory, entry.name));
modules.set(module.info.name, module);
}
}
let modules = await loadModules(this.loadedPlugins);
await patch(this.path("saves", saveName), [...modules.values()]);
return saveName;
}
@@ -994,7 +1051,7 @@ end`.replace(/\r?\n/g, " ");
}
await this.sendSaveListUpdate();
await lib.invokeHook(this.plugins, "onStart");
await this.hooks.start.invoke();
this.notifyStatus("running");
}
@@ -1019,7 +1076,7 @@ end`.replace(/\r?\n/g, " ");
await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings);
this._watchServerLogActions();
await lib.invokeHook(this.plugins, "onStart");
await this.hooks.start.invoke();
this.notifyStatus("running");
}
@@ -1101,6 +1158,10 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceAdminlistUpdateEvent(request: lib.InstanceAdminlistUpdateEvent) {
if (await this._waitForSettledStatus() !== "running") {
return;
}
const { name, admin } = request;
const sync = this.config.get("factorio.sync_adminlist");
if (sync === "disabled") {
@@ -1114,6 +1175,10 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceBanlistUpdateEvent(request: lib.InstanceBanlistUpdateEvent) {
if (await this._waitForSettledStatus() !== "running") {
return;
}
const { name, banned, reason } = request;
const sync = this.config.get("factorio.sync_banlist");
if (sync === "disabled") {
@@ -1127,6 +1192,10 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceWhitelistUpdateEvent(request: lib.InstanceWhitelistUpdateEvent) {
if (await this._waitForSettledStatus() !== "running") {
return;
}
const { name, whitelisted } = request;
const sync = this.config.get("factorio.sync_whitelist");
if (this.config.get("factorio.sync_whitelist") === "disabled") {
@@ -1155,7 +1224,7 @@ end`.replace(/\r?\n/g, " ");
// XXX this needs more thought to it
if (this.server._state === "running") {
await lib.invokeHook(this.plugins, "onStop");
await this.hooks.stop.invoke();
await this.server.stop();
await this.sendSaveListUpdate();
} else if (
@@ -1174,17 +1243,17 @@ end`.replace(/\r?\n/g, " ");
}
async handleControllerConnectionEvent(event: lib.ControllerConnectionEvent) {
await lib.invokeHook(this.plugins, "onControllerConnectionEvent", event.event);
await this.hooks.controllerConnectionEvent.invoke(event.event);
}
async handlePrepareControllerDisconnectRequest() {
await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this);
await this.hooks.prepareControllerDisconnect.invoke(this);
}
async handleInstanceMetricsRequest() {
let results: ReturnType<typeof lib.serializeResult>[] = [];
if (!["stopped", "stopping"].includes(this._status)) {
let pluginResults = await lib.invokeHook(this.plugins, "onMetrics");
let pluginResults = await this.hooks.metrics.collect();
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(lib.serializeResult(metric));
@@ -1208,6 +1277,9 @@ end`.replace(/\r?\n/g, " ");
async handleInstanceStartRequest(request: lib.InstanceStartRequest) {
let saveName = request.save;
if (saveName !== undefined) {
checkRequestSaveName(saveName);
}
try {
try {
await this.prepare();
@@ -1267,6 +1339,7 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceCreateSaveRequest(request: lib.InstanceCreateSaveRequest) {
checkRequestSaveName(request.name);
this.notifyStatus("creating_save");
try {
this.logger.verbose("Writing server-settings.json");
@@ -1294,6 +1367,27 @@ end`.replace(/\r?\n/g, " ");
this.logger.info("Successfully created save");
}
async handleInstanceSaveGameRequest() {
if (this._status !== "running") {
throw new lib.RequestError("Instance is not running");
}
const saved = new Promise<void>((resolve, reject) => {
const onSaved = () => {
this.server.off("exit", onExit);
resolve();
};
const onExit = () => {
this.server.off("save-finished", onSaved);
reject(new lib.RequestError("Instance stopped before the save finished"));
};
this.server.once("save-finished", onSaved);
this.server.once("exit", onExit);
});
await this.sendRcon("/server-save");
await saved;
}
async handleInstanceExportDataRequest() {
this.notifyStatus("exporting_data");
try {
+2 -2
View File
@@ -1,6 +1,6 @@
import * as lib from "@clusterio/lib";
import type Host from "./Host";
import type Instance from "./Instance";
import type Host from "./Host.js";
import type Instance from "./Instance.js";
export default class InstanceConnection extends lib.Link {
+171
View File
@@ -0,0 +1,171 @@
// Implementation of the create-scenario command
import fs from "node:fs/promises";
import os from "node:os";
import JSZip from "jszip";
import path from "path";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import { loadModules, patch, SaveModule } from "./patch.js";
import { findVersion } from "./server.js";
/**
* Create a scenario patched with the given modules
*
* Runs the save patching on the scenario at scenarioPath and writes the
* result to outputPath. The scenario can be a directory, a zipped
* scenario or a save.
*
* @param scenarioPath - Path to the scenario to patch.
* @param outputPath - Path to write the patched scenario to.
* @param modules - Description of the modules to patch.
* @param zip - Write a zip file instead of a directory.
*/
export async function createScenario(
scenarioPath: string,
outputPath: string,
modules: SaveModule[],
zip = false,
) {
try {
await fs.stat(outputPath);
throw new Error(`${outputPath} already exists`);
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-scenario-"));
try {
const tempPath = path.join(tempDir, "scenario.zip");
if ((await fs.stat(scenarioPath)).isDirectory()) {
await zipDirectory(scenarioPath, tempPath);
} else {
await fs.copyFile(scenarioPath, tempPath);
}
await patch(tempPath, modules);
if (zip) {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.copyFile(tempPath, outputPath);
} else {
await extractZip(tempPath, outputPath);
}
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function zipDirectory(dirPath: string, zipPath: string) {
const root = path.basename(path.resolve(dirPath));
const zip = new JSZip();
let dirs: [string, string][] = [[dirPath, root]];
while (dirs.length) {
let [dir, relativeDir] = dirs.pop()!;
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
let fsPath = path.join(dir, entry.name);
let relativePath = path.posix.join(relativeDir, entry.name);
if (entry.isFile()) {
zip.file(relativePath, await fs.readFile(fsPath));
} else if (entry.isDirectory()) {
dirs.push([fsPath, relativePath]);
}
}
}
await fs.writeFile(zipPath, await zip.generateAsync({ type: "nodebuffer" }));
}
async function extractZip(zipPath: string, dirPath: string) {
const zip = await JSZip.loadAsync(await fs.readFile(zipPath));
const root = lib.findRoot(zip);
for (let file of Object.values(zip.files)) {
if (file.dir || !file.name.startsWith(`${root}/`)) {
continue;
}
const fsPath = path.join(dirPath, file.name.slice(root.length + 1));
await fs.mkdir(path.dirname(fsPath), { recursive: true });
await fs.writeFile(fsPath, await file.async("nodebuffer"));
}
}
/**
* Yargs create-scenario command
*
* @param yargs - yargs command builder.
*/
export function createScenarioCommand(yargs: any) {
yargs
.positional("output", {
describe: "Path to write the scenario to",
type: "string",
})
.option("zip", {
type: "boolean", nargs: 0, default: false,
describe: "Write the scenario as a zip file instead of a directory",
})
.option("scenario", {
nargs: 1,
describe:
"Path to a scenario directory, zipped scenario or save to patch. " +
"Defaults to the freeplay scenario of the Factorio install in host.factorio_directory",
type: "string",
})
.option("factorio-version", {
nargs: 1,
describe: "Factorio version to take the freeplay scenario from",
default: "latest",
type: "string",
})
.option("plugins", {
describe: "Plugins to patch modules from, defaults to all plugins enabled in the host config",
type: "array",
})
;
}
/**
* Handle yargs create-scenario command
*
* @param args - yargs args object.
* @param hostConfig - Host config.
* @param pluginInfos - Plugins available to the host.
*/
export async function handleCreateScenarioCommand(
args: Record<string, unknown>,
hostConfig: lib.HostConfig,
pluginInfos: lib.PluginNodeEnvInfo[],
) {
let selected: lib.PluginNodeEnvInfo[];
if (args.plugins) {
selected = (args.plugins as string[]).map(name => {
const pluginInfo = pluginInfos.find(info => info.name === name);
if (!pluginInfo) {
throw new Error(`Plugin ${name} is not in the plugin list`);
}
return pluginInfo;
});
} else {
selected = pluginInfos.filter(
info => info.instanceEntrypoint && hostConfig.get(`${info.name}.load_plugin`)
);
}
let scenarioPath = args.scenario as string | undefined;
if (scenarioPath === undefined) {
const factorioDir = hostConfig.get("host.factorio_directory");
const version = args.factorioVersion as lib.TargetVersion;
if (!lib.isTargetVersion(version)) {
throw new Error(`Invalid Factorio version ${version}`);
}
const [dataDir] = await findVersion(factorioDir, version);
scenarioPath = path.join(dataDir, "base", "scenarios", "freeplay");
}
const modules = await loadModules(selected);
logger.info(`Patching ${scenarioPath} with modules ${[...modules.keys()].join(", ")}`);
await createScenario(scenarioPath, args.output as string, [...modules.values()], Boolean(args.zip));
logger.info(`Created scenario ${args.output}`);
}
+8 -6
View File
@@ -5,9 +5,9 @@ import { Jimp, JimpMime, type JimpInstance } from "jimp";
import JSZip from "jszip";
import * as lib from "@clusterio/lib";
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
import type { FactorioServer } from "./server";
import { definesPrototypesFallback } from "./export_fallback";
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js";
import type { FactorioServer } from "./server.js";
import { definesPrototypesFallback } from "./export_fallback.js";
interface SimpleIconSpecification {
icons?: never;
@@ -56,7 +56,7 @@ async function generateExportMod(server: FactorioServer) {
clean: false,
build: true,
pack: true,
sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"),
sourceDir: path.join(import.meta.dirname, "..", "..", "..", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version?.replace(/\.\d+$/, ""),
@@ -160,7 +160,7 @@ async function loadSimpleIcon(
item: SimpleIconSpecification,
size: number,
iconCache: IconCache,
) {
): Promise<Image | null> {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size ?? 64, 0, iconCache);
if (icon) {
let iconScale = size / (item.icon_size ?? 64);
@@ -178,7 +178,7 @@ async function loadLayeredIcon(
item: LayeredIconSpecification,
size: number,
iconCache: IconCache,
) {
): Promise<Image> {
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
let icon = new Jimp({ width: size, height: size });
@@ -590,3 +590,5 @@ export async function exportData(server: FactorioServer) {
// For testing only
export const _exportLocale = exportLocale;
export const _loadSimpleIcon = loadSimpleIcon;
export const _loadLayeredIcon = loadLayeredIcon;
+40 -10
View File
@@ -9,9 +9,6 @@ import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import BaseInstancePlugin from "./BaseInstancePlugin";
/**
* Describes a module that can be patched into a save
*/
@@ -90,9 +87,8 @@ export class SaveModule {
return module;
}
static async fromPlugin(plugin: BaseInstancePlugin) {
let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json"));
let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module");
static async fromPluginInfo(pluginInfo: lib.PluginNodeEnvInfo) {
let moduleDirectory = path.join(path.dirname(pluginInfo.packagePath), "module");
try {
await fs.access(moduleDirectory);
} catch (err: any) {
@@ -103,17 +99,17 @@ export class SaveModule {
let moduleJson;
try {
moduleJson = {
name: plugin.info.name,
version: plugin.info.version,
name: pluginInfo.name,
version: pluginInfo.version,
dependencies: { "clusterio": "*" },
...JSON.parse(await fs.readFile(moduleJsonPath, "utf8")),
};
} catch (err: any) {
throw new Error(`Loading module/module.json in plugin ${plugin.info.name} failed: ${err.message}`);
throw new Error(`Loading module/module.json in plugin ${pluginInfo.name} failed: ${err.message}`);
}
if (!lib.ModuleInfo.validate(moduleJson)) {
throw new Error(
`module/module.json in plugin ${plugin.info.name} failed validation:\n` +
`module/module.json in plugin ${pluginInfo.name} failed validation:\n` +
`${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`
);
}
@@ -149,6 +145,40 @@ export class SaveModule {
}
}
/**
* Load the modules to patch into a save
*
* Collects the modules provided by the given plugins together with the
* modules bundled with the host package.
*
* @param pluginInfos - Plugins to load modules from.
* @returns Map of module name to module.
*/
export async function loadModules(pluginInfos: Iterable<lib.PluginNodeEnvInfo>) {
let modules: Map<string, SaveModule> = new Map();
for (let pluginInfo of pluginInfos) {
let module = await SaveModule.fromPluginInfo(pluginInfo);
if (!module) {
continue;
}
modules.set(module.info.name, module);
}
// Find stand alone modules to load
// XXX for now only the included clusterio module is loaded
let modulesDirectory = path.join(import.meta.dirname, "..", "..", "..", "modules");
for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (modules.has(entry.name)) {
throw new Error(`Module with name ${entry.name} already exists in a plugin`);
}
let module = await SaveModule.fromDirectory(path.join(modulesDirectory, entry.name));
modules.set(module.info.name, module);
}
}
return modules;
}
export class PatchInfo {
static currentVersion = 1;
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more