Compare commits

..
Author SHA1 Message Date
Cooldude2606 fc05b229f1 2.0.0-alpha.24 2026-04-10 12:54:27 +01:00
Hornwitser da4599f219 Fix missing ) in changelog script parser output 2026-04-09 23:46:35 +00:00
Hornwitser a7620d3cdc Add target variables in changelog script parser
Add variables specifying the repository and branch that the changelog
script parser operates on in order to make it easier to use this script
on other projects.
2026-04-09 23:46:35 +00:00
Hornwitser 2e48b728dd Remove fs-extra and klaw (#882)
These packages are obsoleted by the promises API to the built-in Node fs
module.  The fs-extra package is also causing problems with broken APIs
that don't behave the same as Node's fs module, and throwing errors
without code properties on them.
2026-04-09 22:53:13 +02:00
Cooldude2606 130dca4261 Merge pull request #885 from Cooldude2606/feature/load-plugin-validation
Allow plugins to specify requiring save patching / script commands
2026-04-09 21:48:27 +01:00
Cooldude2606 2afd7aab38 Fix failing tests 2026-03-24 18:25:53 +00:00
Cooldude2606 622d372776 Add feature flags to first party plugins 2026-03-24 17:44:30 +00:00
Cooldude2606 790c2834f2 Add validator to load_plugin 2026-03-24 17:36:19 +00:00
Hornwitser 71dfb8ca24 Support node: prefix in web_ui (#881)
The Node.js documentation's examples all use this prefix, and there are
now builtin modules in Node.js only accessible via it.  Add support for
using it in lib code shared in the web_ui.
2026-03-21 01:39:24 +01:00
Cooldude2606 81e419dad4 Merge pull request #856 from Cooldude2606/feature/config-validation
Add extended config validation
2026-03-21 00:15:16 +00:00
Cooldude2606 93271730eb Make configs readonly inside validators 2026-03-20 18:56:55 +00:00
Cooldude2606 5d0985cafc Fix tests not checking that validators are called 2026-03-20 17:42:48 +00:00
Cooldude2606 6ade75e4cd Fix incorrect config validators 2026-03-20 17:07:54 +00:00
Hornwitser 86eefab9f5 Refactor file write streams (#877)
A lot of code is repeated in various different ways in order to handle
streaming content to files without overwriting them, whether it's from
web resources or local streams.

Refactor the handling of these streams to all use the same utility
function for creating a write stream, and for the places downloading
urls use the same downloadFile function.
2026-03-19 21:55:52 +01:00
Hornwitser 7701010fee Merge pull request #875 from Hornwitser/refactor-export
Refactor export
2026-03-19 21:55:04 +01:00
Hornwitser b3dc262862 Remove error prone refCount logic
This can simply use the length of the callbacks array instead.
2026-03-19 16:10:54 +01:00
Cooldude2606 f0fb52ab74 Revert change to mock exports 2026-03-18 00:17:17 +00:00
Cooldude2606 ed4fe3230d Fixed test causing missed coverage 2026-03-18 00:03:44 +00:00
Cooldude2606 8217acef3f Add coverage of config requests 2026-03-17 23:42:41 +00:00
Hornwitser 019737286f Handle missing export in locale and metadata store
In the future, removing exports is likely going to be a feature.  Handle
the case where the export data is removed from a mod pack in the locale
and prototype metadata stores.
2026-03-17 23:32:04 +01:00
Hornwitser d90ac998aa Handle incorrect usage of subscribe in stores
If the unsubscribe function is called multiple times the indexOf
function will return -1 which will end up removing random other
subscription callbacks.  Prevent the incorrect callabck removial
in this case.
2026-03-17 23:23:03 +01:00
Hornwitser 781b98b1c1 Explicitly compare refCount with 0 2026-03-17 23:21:56 +01:00
Cooldude2606 4986c9af99 Complete coverage of config classes 2026-03-17 21:31:46 +00:00
Cooldude2606 4868ed2d63 Merge branch 'master' into feature/config-validation 2026-03-16 23:22:54 +00:00
Cooldude2606 2510d47023 Add tests 2026-03-16 23:20:40 +00:00
Hornwitser 0d28a3ac3c Refactor web_ui handling of exports
The handling of exported data such as locales and icons in web_ui was
based on a model where there is only one set of data exported per
cluster.  This model changed to have one set of exports per mod pack
when mod packs were introduced, but the existing web_ui code was
bodged to use exports from a "default mod pack" on the controller.

Refactor the code loading exported data to take the mod pack as an
argument for selecting which exported data to load.  The different kinds
of prototype metadata that can be loaded is consolidated into one
`useExportPrototypeMetadata` hook.  The logic for displaying icons is
put into a `FactorioIcon` component.  And the obsolete
`useExportManifest` hook is removed.

These changes allow the `/icon-reference` page to display exports on a
per mod pack basis.  Which has now been linked on the bottom of the mod
pack view page.
2026-03-16 17:11:47 +01:00
Hornwitser f4ee2005e3 Fix bad changelog entries for alpha 23 [skip ci] (#873) 2026-03-16 13:38:41 +01:00
Hornwitser 4b85367d4e Expose lib.isDeepStrictEqual
This utility is useful when comparing deep structures for equality in
places like the web_ui.
2026-03-13 18:34:17 +01:00
Hornwitser 433cc3c712 Add missing model exports to web_ui package
A lot of the models were not available to plugins due to the index page
not exposing them on the package.
2026-03-13 18:33:57 +01:00
Hornwitser ab5162e581 Add changelog script parsing entries from PRs (#871)
Add script which uses GitHub's REST API to fetch issues and pull
requests that have changed since the last release, parse out the
changelog entries from merged pull requests, and print a formatted
version of the changelog suitable for copying into the release
description, the CHANGELOG.md file and the Discord announcement.

The script expects Pull Requests to have a `### Changelog` section in
them with a code block containing changelog entries.  As an added bonus
the script can parse out `#<number>` references so that you do not need to
write it out in the ugly `[#id](long-url-with-id-repeated)` format.

Updates documentation and pull request template to describe the
changelog format and usage of the formatting script.

Includes improvements from review and a second look.
2026-03-13 15:33:24 +01:00
Hornwitser 3b8058de81 Refactor icon export to be based on prototypes
Use the defines.prototypes data structure from the game to find which
prototype sub-types belong in the same base type, and export icons based
on these base type.

This renames the planets category to space-location, signal to
virtual-signal and plits item up into item and fluid.  And changes the
exported metadata to include prototypes with no icons, as future code
might want to use it to for example enumerate all item prototypes.
2026-03-12 18:26:04 +01:00
Hornwitser f4916594b8 Update jimp to version 1.6.0
Resolves #854
2026-03-12 14:45:48 +01:00
Hornwitser 4c01f6a789 Fix export missing localised name
The change in #838 removed localised names from the exported metadata.
Add it back again so that items with overriden names are translated.
2026-03-12 14:45:48 +01:00
Hornwitser 076fb933dc Share structured data types in export
Place shared types of the export functionality into lib and use in the
web interface to make sure the data is not incorrectly handled.

Resolves #526
2026-03-12 14:45:48 +01:00
Hornwitser bcabad567e Distinguish simple and layered icons
This allows the type to be automatically inferred and removes the need
for type casts.
2026-03-12 14:45:48 +01:00
Cooldude2606 414ef0bc8b Some fixes to config setting 2026-03-11 00:28:36 +00:00
Cooldude2606 9d807fc6d6 Update webui config to use bulk set 2026-03-10 23:38:22 +00:00
Cooldude2606 c43f752eaf Add ConfigSet requests 2026-03-10 23:16:58 +00:00
Cooldude2606 6e8290cc08 Add full staging to config class 2026-03-10 22:56:50 +00:00
Cooldude2606 bbd3cc7fdb Remove pnpm version from publish workflow 2026-03-10 19:13:29 +00:00
Cooldude2606 90c742ace6 2.0.0-alpha.23 2026-03-10 19:03:18 +00:00
Hornwitser 0d9573b949 Merge pull request #868 from Hornwitser/small-things-16
Small things 16
2026-03-10 02:51:31 +01:00
Cooldude2606 b1f63c9cac Merge pull request #870 from Cooldude2606/fix/missing-await-prepare
Add missing awaits to Instance.prepare
2026-03-10 01:50:00 +00:00
Hornwitser 76099d2a42 Document unreachable code paths in keyToLabels
These were added to as a diagnostic in case the logic turns out to be
broken.  They should not be possible to reach as any value produced by
labelsToKeys will not cause the error conditions to occur.
2026-03-10 02:35:09 +01:00
Hornwitser 60eb2389a3 Test every ASCII character in prometheus test
The a and A felt lonely being the only letters included. Add the other
letters in the ASCII character set as there was no good reason to leave
them out.
2026-03-10 02:19:01 +01:00
Hornwitser 86a648e759 Detail requirements for instance waking requests
These need to make sure instance status is correctly handled, otherwise
they may cause the instance or host to become inoperable.
2026-03-10 02:17:36 +01:00
Hornwitser 66c066616b Add test for Controller.factorioVersions failure
Check that errors from fetchFactorioVersions is correctly handled in the
fetch method provided by the Controller class.
2026-03-10 02:14:44 +01:00
Hornwitser 30f552847a Move FactorioVersionsRequest to BaseConnection
No reason to duplicate this in both the instance and host connection if
the implementation is identical in both.
2026-03-10 02:13:13 +01:00
Cooldude2606 b75e2da480 Add missing awaits to Instance.prepare 2026-03-10 00:34:41 +00:00
Cooldude2606 eb09413d8b Merge pull request #866 from Cooldude2606/fix/server-whitelist
Always create a whitelist file so it can be watched for changes
2026-03-10 00:32:19 +00:00
Hornwitser 05ae607a6a Fix tests for checkForUpdates
Fix incorrect suite name and attempting to test no version matches using
the "latest" version which always matches in the tests for the
FactorioServer.checkForUpdates method.
2026-03-09 20:09:07 +01:00
Hornwitser f6eb55163f Fix metrics broken by comma and equal in labels
The logic for turning a key back into the labelles that defined it would
not account for the labels values possibly including a comma or equal
sign, which in turn broke the logic for handling these metrics.

Fix by properly parsing the keys.
2026-03-09 19:35:02 +01:00
Hornwitser b0595a6502 Fix identical updatedAtMs values in datastore
The assumption with the subscription system is that new updates will
always increment the updatedAtMs value to something greater than what
has been seen before.  This might not happen if the system clock is
adjusted, or updates are sent so fast that Date.now() returns the same
value.

Fix these edge cases so that the timestamp is always incremented.
2026-03-09 17:41:11 +01:00
Hornwitser 2399fb9d15 Fix NpmButton fetching config on every render
The useEffect updates list has the major pitfall that passing an object
via for example a prop like foo={{ bar: 1 }} to it causes it to to
detect it as a change on every re-render due to the newly created object
comparing unqual to the previously passed in object.

Unpack the object to prevent the identity from changing every time the
metrics update on the Host page.
2026-03-09 15:58:35 +01:00
Hornwitser 41b9eb1005 Avoid redirect in fetchFactorioVersions
Speed up fetching of result by avoiding a roundtrip due to path being
redirected to a path ending in a /.
2026-03-09 15:32:43 +01:00
Hornwitser 2f70db4b7e Ignore errors fetching Factorio versions
If an error occurs trying to fetch the list of Factorio versions log a
warning and then return an empty list instead of preventing the system
from operating.
2026-03-09 15:16:46 +01:00
Hornwitser 25d98d2ab3 Fix error starting instance on unlisted versions
The FactorioServer.checkForUpdates function would wrongfully assume that
the version running would always be listed on Wube's archive page, and
throw an error if this was not the case.  Ignore version checking logic
if no suitable latest version could be found.
2026-03-09 15:16:26 +01:00
Hornwitser f56605600f Fix host bricked on Factorio version check
If the FactorioVersionRequest throws the instance and host ends up
bricked as the instance is left in an invalid starting state that
can't be stopped.
2026-03-09 14:12:40 +01:00
Hornwitser 0617552956 Fix circular import breaking --dev mode
A circular import caused code to attempt to use symbols before they were
defined.  Due to the --dev mode on the controller running with different
chunck boundaries this only appeared in the --dev mode.
2026-03-09 13:54:08 +01:00
Ryan WalkerandSolar ff784cc772 Differentiate RCON output for improved clarity (#864)
* #793 - Differentiate RCON output

* -removed <div>
-simplified animation logic.
-removed box-shadow in favor of outline

* -remove Rube Goldberg machine
-removed un needed .css rule

* removed redundant null check

* added an extra line  (2 spaces between imports and rest of file)

---------

Co-authored-by: Solar <solar@homelab.local>
2026-03-08 19:44:42 +00:00
Cooldude2606 5b5b2c28f7 Always write a whitelist on server start 2026-03-08 18:51:45 +00:00
Cooldude2606 101886e522 Downgrade error to a warning as it does not prevent server startup. 2026-03-08 17:52:35 +00:00
Hornwitser 276c36b6e9 Throw error if lib is imported twice (#863)
The lib library was written with the assumption that there's only one
copy of it in the whole application and everything imports that one
copy.  Unfortunately there are quite a few circumstances were package
managers end up installing multiple copies of lib and then different
parts use different versions of the library.

This causes Clusterio to break with strange and hard to track down
errors.  Most notably permissions and link messages for a plugin may get
registered to one copy of lib while the controller uses a different copy
and thus does not see or register that what the plugin added exists.

Detect this condition when importing @clusterio/lib and throw an error
when it has been imported twice in order to prevent the broken
installation from causing future hard to debug issues.
2026-03-06 18:50:18 +01:00
Cooldude2606 45f0b94971 Merge pull request #845 from Cooldude2606/feature/sync-recipe-notifications
Add recipe notification sync to inventory sync
2026-03-06 17:23:33 +00:00
Cooldude2606 5d9a6cdff6 Moved nil failed_deserialization to remove_player 2026-03-06 17:17:15 +00:00
Cooldude2606 f2833eec33 Add script data to allow persistence of unknown recipes 2026-03-06 16:05:57 +00:00
Cooldude2606 4badc2e9c1 Fix sync when joining a new server 2026-03-06 15:35:49 +00:00
Hornwitser 05b6bf8c04 Replace remaining usage of phin (#862)
Replace the remaining places in the codebase using phin with fetch.
With the exception of HttpCloser which uses the http/https request
method instead.
2026-03-06 15:27:12 +01:00
Cooldude2606 3f6203f125 Add valid recipe check and remove empty table check 2026-03-06 13:27:45 +00:00
Hornwitser ef0baaa30f Merge pull request #857 from Hornwitser/remote-tls-ca
Remove tls_ca config option
2026-03-05 17:22:17 +01:00
Hornwitser aef5d57776 Upload test logs for debugging CI failures (#858)
The CI often fails with cryptic errors that would be much easier to
debug if the logs from Factorio was provided.  Upload the cluster log
that was written during the tests which includes Factorio logs so that
these can be inspected after a test failure.
2026-03-05 01:32:00 +01:00
Cooldude2606 d76902cebe Add imperfect dependency validation 2026-03-05 00:01:55 +00:00
Cooldude2606 0fa96521c5 Rename extendedValidation to validator 2026-03-04 21:08:20 +00:00
Cooldude2606 61232e3537 Export validators on Config class as static 2026-03-04 21:07:30 +00:00
Cooldude2606 014b18caf8 Fix type of controller.auth_secret 2026-03-04 21:02:39 +00:00
Hornwitser 8a51e9cf4d Use an absolute NODE_EXTRA_CA_CERTS path in test
Remove some complexity around correctly handling a relative path to
extra CA certs by setting it to an absolute path.
2026-03-04 18:27:07 +01:00
Hornwitser fb3fe09466 Remove tlsCa option
The migration from phin to fetch in #755 was hampred by the lack of an
option to set the CA when using fetch.  This meant places that used phin
to talk to the controller with the tlsCa option could not be migrated to
use fetch.

Remove the tlsCa option to unblock this dependency. The replacement for
this option is to set the NODE_EXTRA_CA_CERTS environment variable to a
path to the certificate file instead, which works with fetch.
2026-03-04 17:43:32 +01:00
Hornwitser a106851cdc Enable pnpm shell emulator
Some of our scripts assume a POSIX sh like shell to work, and writing
any sort midly complex script that works with both cmd and sh is next
to impossible.

Fortunately pnpm has an option to emulate a sh like shell on Windows.
See https://pnpm.io/cli/run#shellemulator for more info.
2026-03-04 17:30:28 +01:00
Cooldude2606andHornwitser 687a384fe4 Use an async arrow function for webui rules
Co-authored-by: Hornwitser <github@hornwitser.no>
2026-03-04 14:34:09 +00:00
Cooldude2606 b2daf26e6c Add warnings to web ui 2026-03-04 00:28:29 +00:00
Cooldude2606 d0604f892b Add simple validators to config 2026-03-03 23:55:23 +00:00
Cooldude2606 d05a529c52 Improve config field definition type 2026-03-03 22:22:59 +00:00
Cooldude2606 cdfa9860bb Fix IPC type 2026-03-03 20:30:18 +00:00
Hornwitser 9877946d68 Split up commands into separate modules (#844)
Ctl's commands.ts was starting to grow rather big, and future features
and refactors are only going to make it bigger.  Split it up into
separate modules grouped by which category of commands it serves.
2026-03-03 01:18:09 +01:00
Cooldude2606 3e7b65264e Invert notifications array to prevent loss of new notifications 2026-03-02 17:58:45 +00:00
Cooldude2606 54557a79c5 Do not add empty array for recipe notifications 2026-03-02 16:39:20 +00:00
Cooldude2606 412c291193 Add recipe notification sync to inventory sync 2026-03-01 23:58:57 +00:00
HornwitserandHornwitser 94692dc418 Fix tests failing due broken Factorio detection (#839)
* Fix tests failing due to missmatched Factorio detection

When the configured factorio directory is full of junk then the tests
use the first directory it finds for one of its tests, while the server
code uses a more elaborate logic to figure this out.

Turns out this is complicating things more than needed. Simply trying
each entry in the directory and seeing if it works when treated as a
directory always work, regardsless of symlinks, junctions,
reparse-points or whatever else filesystem magic there is.

* Skip tests requiring Factorio if it's not present

Prevent confusing test failures by skipping all the tests that require a
working Factorio installation to sucteed, and print a warning at the end
of the test suite if tests may have been skipped due to this.

* Handle ENOTDIR when determining Factorio version

If the path to the factorio install contains a component that is not a
file then ENOTDIR may be returned on POSIX like systems.  Treat this the
same as ENOENT so that detection logic works when used on a file.

---------

Co-authored-by: Hornwitser <code@hornwitser.no>
2026-03-01 23:06:03 +00:00
Ryan WalkerandSolar 6733aed3fb Extended export data to cover more icon types (#838)
* Enhance export functionality and add icon reference page

- Expanded exported assets to include additional spritesheets and metadata for recipes, signals, technologies, planets, qualities, and entities.
- added logging metrics to game logs
- Introduced a new Icon Reference page to display available icons with filtering capabilities.
- Updated ExportManifest documentation to reflect new asset categories.

* Add support for exporting static icons and update metadata handling

* -added data export snapshot metrics

* Fix HTML attribute syntax in IconReferencePage component

* Applied PR sugestions:
-removed un needed x === "string"s
-removed isntancename / modpack from icon reference and export
-logging per line on export
-fixed variable names
-linting

* linted

* Shows  "no export data available" when no export happens

* -single sprite sheet
-removed static icons with no prototype

* Fix formatting

* Rename types to allowedTypes  for clarity

* -removed duplication check (redundant)
-fixed bug overwriting metadata with same name from previous categories

* Rename asset keys for consistency in export validation  test

---------

Co-authored-by: Solar <solar@homelab.local>
2026-03-01 23:05:39 +00:00
Cooldude2606andHornwitser 2235294c59 Refactor user and instance manager (#837)
* Refactor UserManager to use Datastore

* Rename lib.User to UserDetails also adds to/fromUserDetails

* Fix existing tests

* Add user class tests

* Add tests for finaliseUsers

* Move HostDetails to own file with tests

* Move InstanceDetails to its own file with tests

* Rename HostInfo and add tests

* Rename InstanceInfo and add tests

- Also changes the names of the host properties related to instances
- Also removes the need to specify the location of InstanceInfo

* Make json round trip test validate schema

* Add InstanceManager

* Fix lint errors

* Extra tests

* Add final test coverage

* Add one last test to InstanceManager

* Response to comments

* Use generator rather than return anonumous generator

Co-authored-by: Hornwitser <github@hornwitser.no>

* Fix lint issue with generator

---------

Co-authored-by: Hornwitser <github@hornwitser.no>
2026-03-01 23:04:37 +00:00
Daniel 5bea930d05 Merge pull request #836 from Cooldude2606/fix/singleplayer-inv-sync
Don't attempt to inc sync in single player
2026-02-26 10:51:53 +01:00
Daniel 385affbcaf Merge pull request #831 from Cooldude2606/feature/automatic-factorio-download
Add automatic headless download and update on linux
2026-02-19 21:46:35 +01:00
Daniel c5c6759868 Merge pull request #832 from Cooldude2606/refactor/move-ensure-role
Move ensure default roles onto Role class
2026-02-19 21:45:45 +01:00
Cooldude2606 02c407ef9f Don't attempt to inc sync in single player 2026-02-10 21:54:28 +00:00
Daniel dd5e3b876e Merge pull request #834 from Cooldude2606/fix/mod-dep-parser
Fix mod dependency parser breaking with inconsistent spaces
2026-02-10 16:34:10 +01:00
Cooldude2606 acf711fbb0 Refactor how dep resolution errors are displayed 2026-02-09 21:35:54 +00:00
Cooldude2606 aeb0c3f5f7 Fix mod dep parser breaking with spaces 2026-02-08 22:56:51 +00:00
Cooldude2606 0a5d0c374b Response to comments
- Increase timeout on download test
- Fix wrong function being called
2026-02-08 21:44:04 +00:00
Cooldude2606 e9974c0f21 Fix whitespace and test names 2026-02-07 01:34:22 +00:00
Cooldude2606 1228baa9a3 Move ensure default roles onto Role class 2026-02-07 01:27:59 +00:00
Cooldude2606 098aceaf10 Forgot to restore fetch in live test 2026-02-05 16:34:43 +00:00
Cooldude2606 fd43a0d238 Fix two tests 2026-02-05 16:26:17 +00:00
Cooldude2606 dc3205148e Add missing tests 2026-02-05 16:17:34 +00:00
Cooldude2606 372c1bffe1 Add more tests and missing return 2026-02-05 15:43:28 +00:00
Cooldude2606 b4338a209c Readd support for direct installs 2026-02-05 14:30:48 +00:00
Cooldude2606 4cbc30f627 Fix tar expecting gz when xz was provided 2026-02-05 13:16:03 +00:00
Cooldude2606 44abeace88 Add automatic headless download and update 2026-02-05 00:45:53 +00:00
Cooldude2606 1c7a32113a Remove single dir factorio install 2026-02-04 22:16:56 +00:00
Daniel 68a3c11d41 Merge pull request #830 from Cooldude2606/webui/better-host-tokens
Improve the generate host token modal
2026-02-03 20:56:40 +01:00
Cooldude2606 c0fd21a665 Improve the generate host token modal 2026-02-02 22:17:52 +00:00
Daniel 12b52a854e Merge pull request #827 from Cooldude2606/feature/startedAtMs
Feature/started at ms
2026-02-02 22:26:17 +01:00
Cooldude2606 ccd5232e70 Fix system info migration logic 2026-02-01 22:35:32 +00:00
Cooldude2606 567a27f0c4 Fix migration tests 2026-02-01 22:23:00 +00:00
Cooldude2606 b019c37d3d Change title on instance start time 2026-02-01 22:11:38 +00:00
Cooldude2606 dec3ad9430 Move and add icons to status tags 2026-02-01 22:07:23 +00:00
Cooldude2606 9729743a29 Add startedAtMs to all processes 2026-02-01 21:30:00 +00:00
Cooldude2606 a4e4621eaa Git ignore config lock files 2026-01-30 21:22:36 +00:00
Cooldude2606 4337555ea1 Fix gitignore "factorio" rule 2026-01-30 20:54:59 +00:00
CopilotandDanielv123 9018397b09 Fix logistics data on servers without logistic researched (#809)
* Initial plan

* Add logistics technology validation to prevent load failures

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2026-01-28 15:03:47 +00:00
Danielv123 1c9aae5909 Fix crash in logging 2026-01-27 15:59:35 +01:00
Daniel 6489fa4048 Merge pull request #823 from Cooldude2606/fix/update-plugin-ts-config
Updates package jsons and tsconfigs to have catalogs and correct peer deps
2026-01-25 20:10:14 +01:00
Daniel 22850b7cd0 Merge pull request #815 from Cooldude2606/feature/database-lock
Add extra startup warnings to CLI: User count and Lock file
2026-01-25 20:08:36 +01:00
Daniel f65c8fb9a0 Merge pull request #816 from Cooldude2606/fix/download-headless
Fix headless download
2026-01-25 20:08:18 +01:00
Daniel 3232da71e2 Merge pull request #817 from Cooldude2606/fix/disable-achivement
Fix achievements not being disabled when save patching disabled
2026-01-25 20:08:05 +01:00
Daniel c9fc4f1c8f Merge pull request #821 from Cooldude2606/feature/version-selector
Add drop down version selector based on Wube version archive
2026-01-25 20:07:38 +01:00
Cooldude2606 0c56c56f74 Fixed duplicate pnpm version spec 2026-01-25 00:26:53 +00:00
Cooldude2606 2928b9b7c5 Bump node and pnpm version 2026-01-25 00:23:09 +00:00
Cooldude2606 1fc6971278 Switch to catalogs 2026-01-25 00:02:20 +00:00
Cooldude2606 2679851d66 Update plugin template 2026-01-24 22:54:59 +00:00
Cooldude2606 ab86cd2b1c Update package json and tsconfig 2026-01-24 22:47:40 +00:00
Cooldude2606 02fce63e11 Fix git ignore 2026-01-24 21:03:57 +00:00
Cooldude2606 8981a018b0 Fix lint issue 2026-01-24 18:33:28 +00:00
Cooldude2606 bbf5df6d01 Add remaining tests 2026-01-24 17:58:36 +00:00
Cooldude2606 6f68cf74a7 Add network events and switch to tree select 2026-01-24 16:27:28 +00:00
Cooldude2606 07f501a67a Add ValueCache and fetchFactorioVersions 2026-01-23 01:50:20 +00:00
Daniel 4caf06aafc Merge pull request #819 from Cooldude2606/feature/login-screen-name
Display cluster name on login screen
2026-01-22 15:45:00 +01:00
Cooldude2606 4901d76ebe Refactor version inputs 2026-01-22 00:37:34 +00:00
Cooldude2606 6b0dd4e9cf Add tests for new api route 2026-01-21 23:47:47 +00:00
Cooldude2606 a2c38ad4e8 Add cluster name to login form 2026-01-21 23:42:53 +00:00
Cooldude2606 438ccbdc7f Fix achievements not being disabled 2026-01-14 21:24:03 +00:00
Cooldude2606 d6c5430e0c Fix headless download 2026-01-11 17:05:21 +00:00
Cooldude2606 d48b731ea4 Add lockfile bypass 2026-01-08 22:48:00 +00:00
Cooldude2606 bccb6527a7 Add lock file for configs 2026-01-07 01:20:40 +00:00
Cooldude2606 00cf0f59ec Refuse to start controller with no users 2026-01-07 00:21:56 +00:00
Double 3a823bee01 Update link to message passing protocol specification (#813) 2026-01-01 19:23:54 +00:00
Cooldude2606 98650b9bfb 2.0.0-alpha.22b (plugins)
Due to the bump of lib version, all plugins must also be bumped to 22b
2025-11-30 22:07:37 +00:00
Daniel a781af5b6a Merge pull request #811 from clusterio/copilot/fix-add-mods-button-visibility
Fix button overflow on narrow screens across web interface
2025-11-22 23:21:55 +01:00
copilot-swe-agent[bot]andDanielv123 f202a0975a Add wrap to more Space components and fix table scroll on narrow screens
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 10:55:42 +00:00
copilot-swe-agent[bot]andDanielv123 ea4280d184 Add wrap prop to Space components in SectionHeader for mobile responsiveness
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 07:52:22 +00:00
copilot-swe-agent[bot] c519ee1389 Initial plan 2025-11-20 07:45:31 +00:00
CopilotandDanielv123 c61b9dbe60 Remove debug log statement from serialize.lua (#808)
* Initial plan

* Remove debug log statement from serialize.lua

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-11 12:21:07 +01:00
Cooldude2606 77f4aa6296 2.0.0-alpha.22b 2025-11-07 21:55:35 +00:00
Cooldude2606 86a9084d56 Fix package updater 2025-11-07 21:48:09 +00:00
Cooldude2606 43def44136 Fix package path for restart required 2025-11-07 21:47:48 +00:00
Cooldude2606 8c1be7210e Skip git checks on npm publish 2025-11-07 20:08:22 +00:00
Cooldude2606 24e290746d 2.0.0-alpha.22 2025-11-07 19:48:21 +00:00
Cooldude2606 d52931a326 Better handling of mod dependencies (#801)
* Add ModDependency and version types

* Add warnings for mod dependencies

* Add warning for wrong factorio version

* Add fix issues button

* Add automatic dependency resolution and download

* Add abilty to check for updates

* Add extra test coverage

* One extra test

* Fix copy paste error
2025-11-07 19:22:17 +00:00
Cooldude2606 73cc019d2f Sets restart required when a plugin is updated (#794)
* Template type for load plugin config

* Add plugin update checking

* Add restart icon to instance list

* Add tests

* Log package name when failing to read package json
2025-11-01 16:11:11 +00:00
Cooldude2606 41151d47f0 Add publish workflow (#798)
* Change build-mod to belong to packages

* Add publish script and workflow

* Fix typo in variable name
2025-11-01 16:10:41 +00:00
Cooldude2606 3fbd9a995d Add better plugin index missing error (#799)
* Add better plugin load errors

* Remove check for permission

* Account for packages using different main file
2025-11-01 16:10:24 +00:00
Daniel af299bc08d Merge pull request #800 from Cooldude2606/fix/template-ignore
Fix plugin template npm and git ignore
2025-10-30 13:54:29 +01:00
Hornwitser 93fa12200f Fix installer asking for remoteNpm even when given 2025-10-18 18:43:55 +02:00
Hornwitser 35f769ad25 Release hotfix of installer 2025-10-18 18:27:41 +02:00
Cooldude2606 732cee4837 Fix plugin template npm and git ignore 2025-09-19 23:45:31 +01:00
Cooldude2606 681e78967f Webpack Build Improvements (#791)
* Bump @swc/core to 1.13.3

* Switch to using --mode production rather than --env

Closes #792

* Add cache
2025-08-17 15:54:37 +01:00
Daniel d2bd1382ba Filtered subscriptions (#788)
* Add filtered subscriptions

* Add tests for subscription filters

* Stricter assertions

* Un-nest if statements
2025-08-13 11:55:49 +01:00
Daniel c0ec9a3890 Send inventory on interval instead of event to ensure time gets updated (#790)
This means inventory updates in low-volume clusters might be delayed up to 1 second more, but ensures we always have the unix time signal available, even in clusters that don't have anything in the inventory yet. Resolves #610
2025-08-10 22:25:32 +01:00
Daniel 4221b96431 Fix rendered more hooks error when refreshing hosts page (#789)
* Fix rendered more hooks error when refreshing hosts page

* Fix conditional render on controllerPage

Doesn't cause any issues that I know off right now, but changes to antd internal code could change that so it feels better to be sure.
2025-08-10 22:17:14 +01:00
Daniel 3ddbe2ddb3 Merge pull request #787 from Cooldude2606/fix/plugin-template
Plugin templates no longer depend on a dev install of Clusterio
2025-08-10 21:20:24 +02:00
Cooldude2606 7ceb786143 Add onExit to instance template 2025-08-08 21:41:49 +01:00
Cooldude2606 174d9d5b07 Bug Fixes 2025-08-07 19:14:23 +01:00
Cooldude2606 a1118d08dd Add ^ to clusterio version 2025-08-07 17:44:55 +01:00
Cooldude2606 379d2575a0 Improve comments 2025-08-07 17:42:52 +01:00
Cooldude2606 e30dcc6fff Fix tsconfigs 2025-08-07 17:32:54 +01:00
Cooldude2606 281a5ed97e Update common files 2025-08-07 13:17:41 +01:00
Daniel 6004f861fe Merge pull request #783 from clusterio/cursor/configure-master-listen-address-53c7
Exclude instance from "start all" function
2025-08-04 20:50:47 +02:00
Danielv123 174c798d4b Run integration test instead of unit test 2025-08-04 18:46:13 +00:00
Danielv123 14b8b5ac61 ctl instance stop-all test 2025-08-04 18:28:25 +00:00
Danielv123 89dec572bb Add test for ctl instance start-all 2025-08-04 18:10:48 +00:00
Danielv123 f662880c8d Fix zlib update breaking our types 2025-08-04 17:36:18 +00:00
Danielv123 90cd2770b3 Add start-all command to cli 2025-08-04 17:36:18 +00:00
Cursor Agentanddanielv 69240e7917 Add option to exclude instances from bulk start operation
Co-authored-by: danielv <danielv@danielv.no>
2025-08-04 17:36:18 +00:00
Daniel e1d9c1e414 Merge pull request #785 from clusterio/dependency-updates
Update dependencies
2025-08-04 19:03:49 +02:00
Danielv123 376eaeba52 Revert save writing delay during tests 2025-08-04 16:28:35 +00:00
Danielv123 80983662aa Wait for promise to resolve before returning 2025-08-04 16:14:07 +00:00
Danielv123 9840f22688 Wait for savefile to be moved 2025-08-04 15:36:27 +00:00
Danielv123 b9e2acaaa5 eslint needs to be ecmascript module 2025-08-01 23:29:41 +00:00
Danielv123 7ac8cd3781 Fix mocha arguments leaking into yargs parser 2025-08-01 23:26:47 +00:00
Danielv123 7e2f10e842 Fix linting issues with new rules 2025-08-01 22:26:25 +00:00
Danielv123 256407b9fd Update eslint config file for eslint 9 2025-08-01 22:26:07 +00:00
Danielv123 7979dfaa16 Fix missing import 2025-08-01 21:25:13 +00:00
Danielv123 10a87d47da Convert ArrayBufferLike to Buffer 2025-08-01 21:19:58 +00:00
Danielv123 59709280da Update dependencies in package.json 2025-08-01 21:12:46 +00:00
Daniel 81b656f0ab Merge pull request #782 from clusterio/cursor/implement-factorio-1-1-support-a0fd
Online player list on instance page
2025-08-01 21:59:34 +02:00
Daniel f966c5b965 Merge pull request #780 from Cooldude2606/feature/instance-restart
Add instance restart
2025-07-31 00:18:41 +02:00
Danielv123 56a26848e6 Paginate users on instance page, better search ux 2025-07-31 00:13:02 +02:00
Cooldude2606 28f21bfb70 Fix mutating actions prop 2025-07-30 16:16:29 +01:00
Danielv123 7b4724845f Fix linter error 2025-07-24 22:36:24 +02:00
Danielv123 2053423864 Add proper filters 2025-07-23 15:00:51 +02:00
Danielv123 e7519975ec Use same component for all user tables 2025-07-23 14:21:33 +02:00
Cursor Agentanddanielv 7b7389526b Add online players list component to instance view page
Co-authored-by: danielv <danielv@danielv.no>
2025-07-21 17:41:13 +00:00
Cooldude2606 a464b61d39 Remove redundant status check 2025-07-16 01:41:23 +01:00
Cooldude2606 88ca4563a8 Add tests 2025-07-16 01:25:27 +01:00
Cooldude2606 449bd68fab Add instance restart to ctl 2025-07-16 00:06:44 +01:00
Cooldude2606 59256e77d1 Make the control buttons consistent 2025-07-15 00:49:33 +01:00
Cooldude2606 f11d91bd6b Add missing space to instances and users list 2025-07-14 22:38:06 +01:00
Cooldude2606 e0278d0331 Add instance restart button 2025-07-14 22:34:41 +01:00
Cooldude2606 2baa2e50ac Add InstanceRestartRequest 2025-07-14 21:48:12 +01:00
Daniel 02d14bd98d Merge pull request #778 from Cooldude2606/refactor/test-exec-ctl
Refactor execCtl and Ctl to speed up unit testing
2025-07-10 13:09:27 +02:00
Daniel 952ae21958 Merge pull request #777 from Cooldude2606/refactor/user-datastore
Refactor roles to use datastore and subscriptions
2025-07-10 13:02:38 +02:00
Cooldude2606 50d7d28054 Fix expected error messages for assert rejects 2025-07-06 23:17:52 +01:00
Cooldude2606 2896ec1c15 Add initializeCtl and control reuse in tests 2025-07-06 22:58:39 +01:00
Cooldude2606 6b81e6383d Fix usage of assert.rejects 2025-07-06 22:53:39 +01:00
Cooldude2606 5ed7163ed8 Add missing await statement
Fixes #774
2025-06-14 23:05:14 +01:00
Cooldude2606 df1edec623 Add role subscriptions and data model 2025-06-14 00:28:03 +01:00
Cooldude2606 a5bf8d92d9 Separate roles to their own file 2025-06-13 22:24:56 +01:00
Cooldude2606 af95ca587b Fix datastore types 2025-06-13 22:24:44 +01:00
Cooldude2606 331fc7d947 Add types to event emitters and clean up connections (#772)
* Event types for controller

* Clean up ctl event emitters

* Event types for server

* Event types for lib

* Remove TypedEventEmitter

* Event types for webui

* Fix compile issues

* Small cleanup of connectors
2025-06-13 22:20:50 +01:00
Cooldude2606 5b56aa3fea v2.0.0-alpha.21 2025-05-26 00:29:28 +01:00
Daniel 56479246ed Merge pull request #767 from Cooldude2606/tests/factorio-2.0
Bump tests: Remove Node 18 & Add Factorio 2.0
2025-05-25 23:31:27 +02:00
Danielv123 773c98b31a Prepare factorio 2.0 in devcontainer 2025-05-25 22:46:25 +02:00
Cooldude2606 4f6abca9d7 Bump ts module as per node 20 recommended 2025-05-21 17:13:54 +01:00
Cooldude2606 0bbaef4751 Remove node 18 add factorio 2.0 2025-05-21 17:13:54 +01:00
Cooldude2606 3ff80119e6 Fix flaking tests 2025-05-21 17:13:43 +01:00
Cooldude2606 63e30dd0ee Add missing migrations for system info and tests (#766)
* Add missing migration for SystemInfo

* Add missing migration tests
2025-05-21 17:07:24 +01:00
Daniel 6e01ba18c5 Merge pull request #765 from Cooldude2606/fix/exchange-strings-v2
Impliment decode for map exchange strings v2
2025-05-19 21:17:03 +02:00
Daniel a39911d667 Merge pull request #764 from Cooldude2606/refactor/config-file-path
Refactor config file paths into the config class
2025-05-19 20:52:14 +02:00
Cooldude2606 4c5ab21c64 Fix version comparison 2025-05-18 21:37:46 +01:00
Daniel cca1d4efc0 Merge pull request #763 from Cooldude2606/feature/whitelist-watching
Add bidirectional whitelist sync
2025-05-18 15:50:08 +02:00
Daniel 78d4217a2a Merge pull request #762 from Cooldude2606/feature/remote-update
Add core package updates + plugin listing, updating, and remote install
2025-05-17 23:26:19 +02:00
Cooldude2606 25a5a2257c Fix test that depends on map strings 2025-05-16 19:40:17 +01:00
Cooldude2606 0f8075ad41 Implement v2 map strings 2025-05-16 19:39:43 +01:00
Cooldude2606 e089e2df4c Improve exchange string tests 2025-05-15 16:46:21 +01:00
Cooldude2606 cd20464952 Fix grammar error 2025-05-13 23:14:20 +01:00
Cooldude2606 cb23428507 Respond to comments and add docs 2025-05-12 23:19:14 +01:00
Cooldude2606 f285d7d803 Add tests for save and load 2025-05-12 22:00:40 +01:00
Cooldude2606 117713eda3 Fix silent test for alt host 2025-05-12 21:32:45 +01:00
Cooldude2606 515093abd2 Improve error message logged for config load error 2025-05-12 21:32:44 +01:00
Cooldude2606 726a3538c2 Move save and load into config class 2025-05-12 21:32:44 +01:00
Cooldude2606 17db53403a Move filepath into config class 2025-05-12 21:30:48 +01:00
Cooldude2606 c650f42276 Enable source maps for test setup 2025-05-12 21:30:47 +01:00
Cooldude2606 6989fbdd44 Move defaultAccess to static property 2025-05-12 21:30:47 +01:00
Cooldude2606andCopilot 9f0633abbb Update packages/lib/src/permissions.ts
Copilot Suggestion - Fxies spelling error

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-11 17:33:54 +01:00
Cooldude2606andCopilot 4e6f7c5402 Update packages/lib/src/rce_ops.ts
Copilot Suggestion - A-Z was the intend range.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-11 17:33:26 +01:00
Daniel 0adb45bba4 Merge pull request #758 from Cooldude2606/feature/pending-restart
Add indicators for host version mismatch and required restart
2025-05-11 14:14:39 +02:00
Cooldude2606 be071be8f2 Add recovery startup mode (#757)
* Add recovery startup mode

* Change error code to RecoveryMode
2025-05-10 21:33:45 +01:00
Cooldude2606 7cf13a8b3b Add user search and pagination 2025-05-10 15:52:16 +02:00
Cooldude2606 90de1ecbf0 Fix bidirectional sync test 2025-05-06 18:05:05 +01:00
Cooldude2606 405af0e173 Add bidirectional whitelist sync 2025-05-06 17:14:27 +01:00
Cooldude2606 5e9beb0cf6 Add tests 2025-05-04 03:35:50 +01:00
Cooldude2606 0eb56b1711 Disable options if a system doesn't support it 2025-05-03 01:53:56 +01:00
Cooldude2606 42ad7f8704 Add plugin list to update modal 2025-04-30 00:05:24 +01:00
Cooldude2606 ff35b5c378 Basic functionality of remote npm 2025-04-29 01:40:22 +01:00
Cooldude2606 c120443657 Add checks for updated version on disk 2025-04-27 23:57:58 +01:00
Cooldude2606 96c5f33dbe Add tests for config changes 2025-04-27 23:29:14 +01:00
Cooldude2606 cf9f087cba Add icons for mis-matched version and restart required 2025-04-27 22:35:00 +01:00
Cooldude2606 47c5e0fabd Fix incorrect ip detection in create 2025-04-22 20:52:30 +01:00
Cooldude2606 a9def876e8 Remove phin without introducing new dependencies (#755) 2025-04-15 19:09:25 +01:00
Cooldude2606 eafbc32d3d Fix open file stream error on some systems 2025-04-15 00:02:54 +01:00
Daniel bbf65dea57 Support quality and ghosts for power armor equipment (#753) 2025-04-13 21:32:23 +01:00
Daniel fcd3dbb65b Merge pull request #748 from Cooldude2606/feature/console-log
Add config option for console logging
2025-04-13 21:56:40 +02:00
Daniel f21a3f45bf Merge pull request #712 from O2theC/auto-mod-update - basic mod portal integration
Add mod portal integration and mod downloads to the web interface
2025-04-13 21:52:07 +02:00
Danielv123 1711271ca8 Fix broken test 2025-04-13 21:44:25 +02:00
Danielv123 25e1398229 Revert "fixed mod export command"
This reverts commit 7f4159b6c6.
2025-04-13 18:13:27 +02:00
Danielv123 2d62ff6689 Reduce type duplication 2025-04-13 18:10:17 +02:00
Danielv123 25099c03ba Use config instead of constants, other review commetns 2025-04-13 18:00:52 +02:00
Danielv123 c6f10e2195 Refactor to avoid duplicated code 2025-04-13 14:22:57 +02:00
Danielv123 1f5f28c820 Same styling of mod portal link and mod download button 2025-04-12 00:06:33 +02:00
Danielv123 a79b129b0c Fix and refactor download buttons 2025-04-12 00:03:37 +02:00
Danielv123 e841aa5685 Add tests 2025-04-11 23:54:42 +02:00
Danielv123 35fef5e021 Cleanup redundant code, remove unused code, add checks 2025-04-11 23:22:37 +02:00
Danielv123 de8a7ef884 Support mod portal downloads 2025-04-11 22:19:11 +02:00
Cooldude2606 f3a62e585f Add log filters to web ui (#746)
* Add max level select to logs

Closes: #745

* Add log filter for server actions

Closes: #744

* Respond to comments

Change name of component, clean up onChange anon function
2025-04-10 16:04:05 +01:00
Danielv123 2936973a75 Link to mod portal 2025-04-08 22:54:16 +02:00
Danielv123 d50230becc Cache mods on controller 2025-04-08 22:42:03 +02:00
Danielv123 600a5d5ce1 Search mods clientside 2025-04-08 22:34:32 +02:00
Daniel 81fb223d5e Fix webpack export not found warnings (#750) 2025-04-08 13:13:50 -07:00
Cooldude2606andDanielv123 0e6d33aa82 Add button to download ctl config (#743)
* Move saveJson to util file

* Add button to download ctl config

* Add icon and tooltip to describe what the button does

---------

Co-authored-by: Danielv123 <danielv@danielv.no>
2025-04-08 20:34:50 +01:00
Danielv123 3bf0d48641 Fix linter warnings 2025-04-08 20:53:06 +02:00
Danielv123 3fae97ac8b Fix mod description rendering 2025-04-08 20:52:56 +02:00
Daniel 42cd099eea Merge pull request #749 from Cooldude2606/fix/736
Fix incorrect use of Array.splice
2025-04-06 13:46:47 -07:00
Cooldude2606 916c48844a Fix incorrect use of Array.splice
This should resolve the sync loops for bans and admins
2025-04-06 18:06:05 +01:00
Cooldude2606 ce85149b4c Add config cloning on instance creation (#747)
* Add instance config cloning on creation

* Add test for instance cloning

* Add tooltip to copy config button
2025-04-06 02:00:08 +01:00
Cooldude2606 0055f28f45 Add bulk user actions (Import/Export/Restore) (#741)
* Add "Bulk Actions" modal to users page

* Add control logic without validation

* Add restore and better permission check

* Add controller logic and automatic backup

* Add CTL commands for bulk actions

* Add tests for bulk actions

* Improve description on permissions
2025-04-06 01:42:51 +01:00
Cooldude2606 1d5040dc0c Improve error page with social links 2025-04-05 02:33:24 -07:00
Cooldude2606 60ee4b2c86 Add config option for console logging 2025-03-29 01:41:19 +00:00
Cooldude2606 7cb191d332 v2.0.0-alpha.20.b
Republish controller to use correct web_ui version.
2025-03-23 17:24:17 +00:00
Cooldude2606 c50298d408 Bump version of web_ui to alpha 20
There has been no functional changes or bug fixes made to web ui between this commit and the previous alpha 20 release.
2025-03-23 15:58:22 +00:00
Hornwitser aeac7eedec zip_ops: Deduce root folder from first zip entry (#739)
Some mods, for example cubium, includes bogus folders like __MACOSX in
addition to the root folder where everything is located.  This only
works by chance as Factorio will check for the folder of the first
entry in the zip file and use that.

Match Factorio's logic in findRoot so that these mods work with
Clusterio.
2025-03-21 23:00:30 +00:00
Danielv123 2ae3f9a600 Add mod portal search 2025-03-15 23:13:33 +01:00
O2theC 05790966db removed unused imports 2025-03-15 15:30:00 +01:00
O2theC a60a3bcbd7 fixed type, split download modpack function into downloadmods and updatemods , returns a map of mods and their latest versions 2025-03-15 15:29:59 +01:00
O2theC 61a241b02d added function to update modpacks 2025-03-15 15:29:59 +01:00
O2theC e1c29bcde1 added functions to download mods from a Map of names and versions 2025-03-15 15:29:59 +01:00
O2theC 7f4159b6c6 fixed mod export command 2025-03-15 15:29:59 +01:00
O2theC 4080c1025d added download file function 2025-03-15 15:29:59 +01:00
O2theC 64e429c5f6 got rid of pesky type definitions for function returns and better map merge 2025-03-15 15:29:59 +01:00
O2theC 24c07c0ed1 it use maps now, much safer 2025-03-15 15:29:59 +01:00
O2theC 61249e5e7e no more undefines in that return dict 2025-03-15 15:29:59 +01:00
O2theC a2a3bcd4f8 adjusted function input for getting latest mod version and adding chunking to not hit request limits 2025-03-15 15:29:59 +01:00
O2theC 71c1cffcac moved interfaces to ModStore and made fetchLatestVersionMod functions 2025-03-15 15:29:59 +01:00
O2theC a769c3179a made modRelease and modDetails interface and made a rough function to get the latest version of a mod from it's name 2025-03-15 15:29:59 +01:00
Cooldude2606 6180a0b27b Fix lint error in UsersPage.tsx 2025-03-14 23:43:42 +00:00
Cooldude2606 4e9df0bc21 v2.0.0-alpha.20 2025-02-28 23:55:11 +00:00
Cooldude2606 2c44890100 Move package release to workflow docs 2025-02-28 23:34:24 +00:00
Daniel febdbd4fd6 Document release workflow (#738) 2025-02-28 23:11:59 +00:00
Danielv123 d622ab5127 Fix missing key on users table roles column
Results in inconsistent visual behaviour when creating and deleting roles
2025-02-27 16:13:33 +01:00
Danielv123 a893109a06 Fix NaN in subspace storage database 2025-02-27 00:05:48 +01:00
Cooldude2606 9f888867fe Dep version bumps
Temp Fix #737
2025-02-25 21:09:11 +00:00
Danielv123 12f833fcc3 Add clusterio_lib release changelog 2025-02-25 19:14:32 +01:00
Daniel a16dcd315f Merge pull request #733 from clusterio/feature/subspace-storage-space-age
Feature/subspace storage space age
2025-02-25 18:53:36 +01:00
Danielv123 9cd68fcb36 Bump version number 2025-02-25 18:39:05 +01:00
Danielv123 ebafda5407 Update with comments from review 2025-02-25 16:33:52 +01:00
Danielv123 a9e1bdcc65 Test itemDatabase with quality and migrations from pre quality format 2025-02-16 14:44:49 +01:00
Danielv123 f907455620 Validate quality parameter 2025-02-16 14:44:15 +01:00
Danielv123 19336cdd3b Remove test dependency on old version of subspace mod 2025-02-16 14:30:47 +01:00
Danielv123 5d37712db4 Fix online time export in 2.0 w backwards compat 2025-02-16 13:34:35 +01:00
Cooldude2606 8996671bea Add default mod packs (#735) 2025-02-14 14:47:26 +00:00
Daniel 7b8945ff15 Don't write input stats to both input and output (#734) 2025-02-14 14:47:13 +00:00
Danielv123 fbdf206e31 Test: Don't depend on a specific version of clusterio_lib 2025-01-25 17:55:43 +01:00
Danielv123andCooldude2606 edfe32c59e Simplify checks based on review comments
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2025-01-25 17:39:47 +01:00
Danielv123 f78966860e Build clusterio lib with CI 2025-01-24 19:23:15 +01:00
Danielv123 2162351710 Bump clusterio_lib versions along with subspace 2025-01-24 17:49:04 +01:00
Danielv123 a33aa3b7e5 Show quality in web UI 2025-01-23 18:54:06 +01:00
Danielv123 e8570029b0 Send and return requests 2025-01-23 00:04:00 +01:00
Danielv123 4fd123abc1 Add quality support to subspace storage 2025-01-22 19:46:23 +01:00
Cooldude2606 26269df7d4 Add 2.0.29 freeplay hash
Hot patch to add the updated 2.0.29 control.lua freeplay hash to knoww scenarios.
2025-01-21 20:43:00 +00:00
phidias 52d8822048 Fix human readable time format (#732) 2025-01-20 11:12:34 +00:00
Daniel 97e25eb885 Handle missing logistic requester point (#731) 2025-01-20 11:11:47 +00:00
Cooldude2606 fa1338ddd4 Add decisions log (#714)
* Add decisions log

* Response to Daniel's comments

* Add PR Links
2025-01-13 21:30:06 +00:00
Cooldude2606 4d66dc5de3 v2.0.0-alpha19.b
Add missing prepare step for web_ui package.
2024-12-24 20:26:31 +00:00
Cooldude2606 0baf47c8e7 v2.0.0-alpha.19 2024-12-23 22:08:23 +00:00
Cooldude2606 65d1fe53d6 Update Change Log 2024-12-23 22:02:59 +00:00
Cooldude2606 16bc651afc Add bidirectional sync to admin and ban list (#709)
* Add bidirectional sync to admin and ban list

* Add tests

* Response to comments

* Skip broken tests from change of default value
2024-12-23 18:58:44 +00:00
Cooldude2606 91c1d2e6c6 Fix version variables in compat.lua (#713) 2024-12-23 15:31:04 +00:00
Cooldude2606 ff9ba0271f Expand compat to 0.17 (#711) 2024-12-13 13:48:52 +00:00
Daniel 87746edbf5 Merge pull request #708 from clusterio/fix/cpu-usage-without-wmic
Use Get-Process instead of wmic on windows
2024-12-09 15:25:13 -08:00
Daniel 6f5547b524 Merge pull request #706 from clusterio/feature/stat-exporter-surfaces
Feature/stat exporter surfaces
2024-12-09 15:24:19 -08:00
Danielv123 c0d33d5852 In 2.0, icon_size is optional with a default of 64 - fixes #699 2024-12-09 15:23:45 -08:00
Daniel 485504d885 Recreate instance's required subfolders on startup (#705)
Fixes #698
2024-12-09 15:21:59 -08:00
Daniel b093cd136f Merge pull request #703 from clusterio/feature/connect-to-server
Add connect to server button to web UI using steam launch api
2024-12-09 15:17:12 -08:00
Danielv123 7b34d238ea Use Get-Process instead of wmic on windows 2024-12-03 17:37:40 +00:00
Danielv123 8e22de9d2d Add platform speed and weight metrics
clusterio_statistics_exporter_platform_speed
clusterio_statistics_exporter_platform_weight
2024-12-02 21:35:25 +00:00
Danielv123 9464541d61 Update tests for multi surface support 2024-12-02 21:14:50 +00:00
Danielv123 fd741f9b49 Add clusterio_statistics_exporter_platform_mapping
This allows queries to map between the platform surfaces tracked in the statistics and the name assigned by the player
2024-12-02 21:05:24 +00:00
Danielv123 7934e471a7 Provide production statistics for all surfaces 2024-12-02 21:04:00 +00:00
Daniel 1090197acc Merge pull request #697 from clusterio/fix/json-function-backwards-compatibility
Add backwards compatible json serialization capability
2024-12-01 10:42:05 +00:00
Danielv123 c89b773f26 Reference in readme 2024-12-01 10:38:06 +00:00
Danielv123 c5c73c8c96 Add connect to server button to web UI using steam launch api
Does not work if factorio is already open. Resolves #608
2024-12-01 10:32:25 +00:00
Danielv123 54411bc327 Use builtin version when available 2024-12-01 09:14:04 +00:00
Daniel 98a15d3366 Merge pull request #692 from clusterio/plugin-management
Add plugins automatically
2024-11-29 20:18:42 +00:00
Danielv123 17133dc1f5 Test for compatability with missing dependencies field in package.json 2024-11-29 20:12:53 +00:00
Danielv123 d7792ca369 Log error when a specified plugin does not exist, but allow loading without it
Preventing loading just because a plugin is missing would cause the host to become inaccessible if something went wrong when managing plugins through the remote interface
2024-11-29 20:01:45 +00:00
Danielv123 747c13a715 Only write changed plugin list once 2024-11-29 20:00:42 +00:00
Cooldude2606 583d484573 Merge pull request #701 from Cooldude2606/feature/enum-config-input 2024-11-28 23:22:54 +00:00
Danielv123 9b0a06815b Correctly handle installs with no plugins or dependencies 2024-11-28 20:27:31 +00:00
Danielv123 45ae20051f Fix specifying default quality for 1.1 migration 2024-11-28 20:18:22 +00:00
Danielv123 d1baab5e7f Synchronize space age logistics slots
Requests of the v1 format is automatically upgraded and placed in a new section. Filters in named sections are not synchronized to avoid messing with non-player entities on servers you join. Synchronizing logistics groups falls under the scope of another plugin.
2024-11-28 20:18:22 +00:00
Daniel 6c3c094e94 Prefer linking to issues over pull requests 2024-11-27 17:32:45 +00:00
Daniel 1ed52d8fe6 Create pull_request_template.md 2024-11-27 17:32:45 +00:00
Danielv123 2fb33c9648 Support synchronizing crafting queue in 2.0 with new storage api 2024-11-27 17:28:59 +00:00
Cooldude2606andDanielv123 4248e57dcb Add config migrations (#693)
Co-authored-by: Danielv123 <danielv@danielv.no>
2024-11-26 23:14:56 +00:00
Danielv123 85cdbb1185 Add backwards compatible json serialization capability
resolves #687 using https://github.com/rxi/json.lua
2024-11-26 19:11:53 +00:00
Danielv123 2e729b9e47 Add tests for monorepo plugins and non-plugin packages 2024-11-26 10:33:26 +00:00
Danielv123 427acfe82e Refactor, fix linting issues 2024-11-26 10:18:54 +00:00
Danielv123 79c4bf1441 Ignore missing plugins instead of throwing 2024-11-26 10:07:14 +00:00
Danielv123 fea07e4f55 Add tests 2024-11-26 10:03:35 +00:00
Danielv123 5dbfff9bfd Support for monorepo autodiscovery 2024-11-26 09:23:09 +00:00
Danielv123 37fed2064e Warn when multiple plugins of the same name are detected 2024-11-26 08:49:30 +00:00
Danielv123 f95e1d2998 Only log not-a-plugin for non-core packages 2024-11-26 08:27:29 +00:00
Danielv123 5586b1640b Include keywords for clusterio and factorio in all first party packages 2024-11-26 08:17:45 +00:00
Danielv123 91cbd7a464 Handle files not existing 2024-11-25 18:26:35 +00:00
Danielv123 472177bf39 Add jsdoc, reduce logging 2024-11-25 18:07:51 +00:00
Danielv123 397dc6a5a6 Autodiscover plugins in dependencies with keyword "clusterio-plugin" 2024-11-25 17:23:03 +00:00
Danielv123 636777d935 Add locally provided plugins by default 2024-11-25 12:22:16 +00:00
Cooldude2606 05ca4eb25e Merge pull request #686 from Cooldude2606/feature/2.0-compat
Add 2.0 support for clusterio api and 1st party plugins
2024-11-23 22:39:47 +00:00
Cooldude2606 ebf8504507 Patches after manual testing 2024-11-23 14:27:06 +00:00
Cooldude2606 ba3d74388d Update changelog 2024-11-23 14:27:03 +00:00
Cooldude2606 8b551deda8 Add 2.0 support to module template 2024-11-23 14:26:12 +00:00
Cooldude2606 2ba33d58ed Add 2.0 support to statistics_exporter 2024-11-23 14:26:11 +00:00
Cooldude2606 33e05c244c Add 2.0 support to research_sync 2024-11-23 14:26:11 +00:00
Cooldude2606 4ca5d17cb0 Add 2.0 support to player_auth 2024-11-23 14:26:11 +00:00
Cooldude2606 4ee508d1b2 Add 2.0 support to inventory_sync 2024-11-23 14:26:10 +00:00
Cooldude2606 ad836d3776 Add 2.0 support to core lua 2024-11-23 14:26:10 +00:00
Cooldude2606 201a201f05 Add compat lib 2024-11-23 14:25:57 +00:00
Cooldude2606 4c64ef9d15 Merge pull request #684 from Cooldude2606/feature/2.0-scenarios
Add support for 2.0 builtin mods and scenarios
2024-11-22 13:42:40 +00:00
Cooldude2606 e4f0ab1e8f Merge branch 'master' into feature/2.0-scenarios 2024-11-22 13:36:33 +00:00
Cooldude2606 efa2b76ab0 Take the earlier first join time 2024-11-22 04:06:34 -08:00
Cooldude2606 d80385c947 Add test for user merging 2024-11-22 04:06:34 -08:00
Cooldude2606 bdf02a5ea2 Made user ids case insensitive 2024-11-22 04:06:34 -08:00
Daniel 41d76ff757 Prevent bypassing command permission with leading whitespace 2024-11-21 08:46:22 -08:00
Cooldude2606 c5841b17a0 Add tests for new config value 2024-11-21 08:46:22 -08:00
Cooldude2606 762212747e Added factorio.enable_script_commands 2024-11-21 08:46:22 -08:00
Cooldude2606 feb09a6fe0 Update change log 2024-11-21 08:25:23 -08:00
Cooldude2606 06d9d74b67 Add default autoComplete for credential fields 2024-11-21 08:25:23 -08:00
Cooldude2606 be4c31c10f Add new node LTS to CI 2024-11-21 08:25:23 -08:00
Cooldude2606 1556c0afdd Enable source maps in tests 2024-11-21 08:25:23 -08:00
Cooldude2606 069f568941 Add silent test as npm command 2024-11-21 08:25:23 -08:00
Cooldude2606 4ef930c08c Add file excluded by gitignore 2024-11-13 22:29:21 +00:00
Cooldude2606 3850558d49 Add and fix tests 2024-11-13 22:17:27 +00:00
Cooldude2606 81444cc447 Fix ModPackView and add 2.0 scenario support 2024-11-13 19:11:59 +00:00
Cooldude2606 c714d86c5c Added Builtin Mods 2024-11-13 17:32:14 +00:00
Cooldude2606 c6354ded70 Merge pull request #683 from Cooldude2606/fix/winston-log-format
Bump winston, winston-transport, and resolve type error
2024-11-12 18:11:17 +00:00
Cooldude2606 cb29db0e45 Bump winston-transport@4.9 2024-11-12 17:15:15 +00:00
Cooldude2606 7298ef2ea7 Bump winston@3.17 and resolve type error 2024-11-12 16:46:01 +00:00
Cooldude2606 ace8356f68 Merge pull request #679 from Cooldude2606/fix/whitelist-remove
Fix whitelist remove command with tests. Tests check that set-banned and set-whitelist will send rcon commands to running instances and that it is case insensitive.
2024-11-01 01:29:10 +00:00
Cooldude2606 19b5c4d857 Fix whitelist command 2024-10-31 16:07:58 +00:00
Cooldude2606 f6a40baa55 Added tests for banlist/whitelist rcon commands.
Tests check that set-banned and set-whitelist will send rcon commands to running instances and that it is case insensitive.
2024-10-31 16:02:12 +00:00
Cooldude2606 0afbb69308 Use sync file writes and catch errors to reset state. (#677)
By using sync file writes it prevents interrupts between the state change and the server process starting. Also adding error catching to reset the state following an error writing to the files.
Fixes: #676
2024-10-29 16:50:05 +00:00
Cooldude2606 9e03c743e9 Merge pull request #678 from Cooldude2606/fix/host-user-sync
Fix host being unable to sync user state to instances after controller connection
2024-10-29 16:28:37 +00:00
Cooldude2606 dba2dd246e Update change log 2024-10-29 01:15:58 +00:00
Cooldude2606 198e58c762 Allow host as src for user sync events 2024-10-29 01:03:08 +00:00
Cooldude2606 ef6861a566 Fixed host tests not checking the message was valid 2024-10-29 00:59:17 +00:00
Hornwitser 60e5ed008d Fix unhandled promise rejection in Link tests 2024-10-28 20:15:33 +01:00
Hornwitser 3838bc6a70 Remove "instance" from ConfigLocation
When the config system was ported to TypeScript the "instance" location
was accidentally added to the type of available locations for access
control.  This has never been a valid location as it was deemed too
complicated to work with.  The correct location for instances is
"controller" and "host" as these are the locations the configs are
handled.
2024-10-28 20:15:09 +01:00
Hornwitser a6033f4171 Merge pull request #675 from clusterio/fix-instance-update
Fix instance update
2024-10-28 20:14:18 +01:00
Hornwitser 6f85ec9dd6 Log files written to aid debugging
Log to the verbose level when a datastore file read or written to in
order to make it easier to diagnose whether the files are written or
not.
2024-10-28 20:07:34 +01:00
Cooldude2606 e0b72e568d Mark RCON password as a credential (#673)
* Mark RCON password as a credential

* Update location name and description

* Add controller to array to avoid UB
2024-10-28 18:46:26 +00:00
Hornwitser 34da073800 Fix setting instance config fields not being saved
If only the config of an instance is updated the dirty flag of the
instance is not set, which results in changes being discarded when the
controller shuts down afterwards.

Fix by always triggering the update logic when an instance field is
changed.
2024-10-28 19:30:03 +01:00
Cooldude2606 300db5cbfd Change display title for controller.external_address (#674)
* Rename controller.external_address

* Revert and correct title to avoid breaking change

* Update change log
2024-10-28 17:53:37 +00:00
Cooldude2606 c49bc07e15 Add hidden and readonly config properties (#665) 2024-10-27 23:02:45 +00:00
Cooldude2606 c31671d1e6 Added 2.0 zip file name to version matcher (#671)
* Added 2.0 zip file name to version matcher

Fixes: Install problem ? #668

* Add changelog entry for 671.
2024-10-27 22:53:34 +00:00
Cooldude2606 9d18d18603 Update CI to support testing of multiple factorio versions (#672)
We have plans to support the latest version and one minor previous. 2.0 will be added to CI once enough changes have been made to support it.
2024-10-27 22:24:15 +00:00
Cooldude2606 8a47fd8414 Update tests 2024-09-21 18:09:29 +01:00
Cooldude2606 49d584394d Add security logic to readonly and object support 2024-09-21 17:41:46 +01:00
Cooldude2606 e3eb266704 Add hidden and readonly config property 2024-09-21 01:52:01 +01:00
Cooldude2606 3e6806ff0e Merge pull request #664 from Cooldude2606/feature/role-drop-down
Add InputRole as a drop down selector
2024-09-21 01:22:52 +01:00
Cooldude2606 76349fa780 Merge branch 'master' into feature/role-drop-down 2024-09-21 01:06:07 +01:00
Cooldude2606 d4b0e658c3 Change log updates 2024-09-21 01:03:51 +01:00
Cooldude2606 549ec3d1e7 Add InputRole as a drop down selector 2024-09-21 01:01:36 +01:00
Hornwitser d5f3ac0f4c Merge pull request #663 from Hornwitser/config-credentials
Implement shared Factorio credentials
2024-09-19 00:42:52 +02:00
Hornwitser a09a564358 Log where Factorio credentials taken from
Provide ease of diagnosing credential issues by accurately logging if
the credentials were sourced from the instance config, the host config,
the controller config, or if no credentials were found.
2024-09-18 23:41:45 +02:00
Hornwitser f873f4b369 Add autocomplete attribute to credential fields
Prevent browsers from automatically autofilling the password entry box
for the Factorio credentials by setting the autocomplete property to
new-password on the password input.
2024-09-18 23:10:31 +02:00
Hornwitser 44e5847a1d Implement shared Factorio credentials
Add config entries to controller and host for the Factorio username and
token to use when talking to the Factorio APIs.  If set an username and
token is not set in factorio.settings then these settings are used,
making it easy to configure one set of credentials for a cluster/host.

For running more public provide your own hardware clusters, there's an
option to disable controller sharing the credential with the hosts so
that each host have to set their own credentials.
2024-09-16 23:23:21 +02:00
Hornwitser eedf5b8f67 Credential option for config entries
Implement a credential option to config entries which is a list of
locations the config entry can be read from.  When specified and the
option is a string field this gets rendered as a password field in the
config UI.  If the credential is not readable from control the value of
the fields is cleared when the UI is refreshed.
2024-09-16 23:23:21 +02:00
Cooldude2606 77fea7e295 Fix unrelibable datastore tests (#662)
* Use incrementDateMs

* Change wait time to 15ms

* Fix loop condition
2024-09-14 17:20:52 +01:00
Hornwitser a1c7173dd2 Merge pull request #661 from Hornwitser/windows-rework
Rework Factorio server stop logic
2024-09-14 18:15:19 +02:00
Cooldude2606 dfc406d025 Host shutdown on duplicate connection (#647)
* Update no-shadow lint rule for enums

https://typescript-eslint.io/rules/no-shadow/

* Add websocket close codes enum

* Pass close code and reason to event handlers

* Shutdown host on duplicate connection

* Add connection refusal

* Fix lib/link tests

* Response to comments

* Additional Tests

* Removed missed codes in close event
2024-09-14 16:39:59 +01:00
Hornwitser 10a857c4ad Gracefully handle RCON connection dropping
Handle the edge cases of RCON authentication failing and RCON connection
failing during startup so that server stop still works in these cases.
2024-09-14 16:09:17 +02:00
Hornwitser e4aeafaf14 Set a map limit to speed up integration test 2024-09-14 15:05:13 +02:00
Hornwitser a1e7b24298 Fix lingering killed server flag
Fix killing the Factorio server causing the next run of it to report a
different error when it's killed by an external factor than what's
expected.
2024-09-14 15:05:13 +02:00
Hornwitser 7ff06f2b96 Rewrite Factorio server shutdown logic
Rewrite shutdown logic for Factorio servers to send a /quit command via
RCON for both Linux and Windows when stopping a server to avoid having
two confusingly intermixed code paths for Windows and Unix systems.
This makes shutdown on Windows tell clients the server shut down instead
of abruptly stoping to respond.
2024-09-14 15:05:13 +02:00
Hornwitser 36fc2ac1db Support nested factorio dirs in server test
Source the factorio directory from the host config as with other tests
and handle it pointing to a folder of Factorio installations.
2024-09-14 15:05:13 +02:00
Hornwitser 64cc9c2b64 Use factorioDir for alt-host config
Source the factorio installation from the host config for the alt host
test like with other tests.
2024-09-14 14:58:55 +02:00
Hornwitser c0e631e251 Fix tables sorting being broken
Newer versions of Ant Design requires either dataIndex or key to be
defined on columns for sorting of the tables to work.  Add missing key
entries on columns.
2024-09-12 22:32:42 +02:00
Hornwitser 8e21290b22 Enable skipped tests on Windows
Use the stop host command to stop alt-host so that the tests that depend
on it can also be run on Windows.
2024-09-12 15:26:12 +02:00
Hornwitser a05aef260f Allow iterating through datastores like a Map
Support the for (const [k, v] of datastore) syntax which is commonly
used with maps by adding a Symbol.iterator function returning the
appropriate iterator.
2024-09-04 13:43:56 +02:00
Hornwitser 7547a48fd6 Fix controller not accepting old hosts.json format
Migrate the content of the hosts.json file before attempting to loading
it into the new datastore format.
2024-09-04 13:40:15 +02:00
Arvids Godjuks 33c9542524 Merge pull request #660 from clusterio/bugfix/special-case-base-mod
Fix built-in mods showing up as missing in web UI
2024-09-04 14:36:29 +03:00
Hornwitser 95396ff1c3 Fix this is not a constructor error on startup
When passing methods around they lose their binding to this, which means
the fromJSON methods no longer has this be their class.  Fix by binding
the methods to the class they belong to before passing them on to the
datastore.
2024-09-04 13:32:38 +02:00
Arvids Godjuks 59091d74f5 Fix built-in mods showing up as missing in web ui
Added "builtInMods" array that contains a list of built-in mod names that we check against to not show an error and remove ability to delete those mods from mod packs

Fixes #581
2024-09-04 14:14:55 +03:00
Hornwitser 6226a45d0b Fix safeOutputFile writing to wrong directory (#659)
When renaming a file over another file the operation is only atomic if
both the source file and the target file is in the same directory.  Fix
safeOutputFile writing the temporary file to a different directory than
the target file due to the path separator not being included when
creating the temporary file path.
2024-09-04 00:14:47 +02:00
Hornwitser 84958e3f9d Merge pull request #629 from Cooldude2606/feature/datastore
Impliment datastores to reduce boilerplate
2024-09-04 00:14:16 +02:00
Hornwitser a2ea2f2604 Merge branch 'master' into feature/datastore 2024-09-04 00:08:52 +02:00
Hornwitser 5d97e8e816 Source factorio dir from config-host.json in tests
If a factorio dir is configured in config-host.json then use that in the
tests.  This removes the requirement of having a factorio installation
in ./factorio or linking to one there when running the tests.
2024-09-03 21:58:45 +02:00
Hornwitser f3150a9261 Add tests for Address.addressedTo
Make sure the confusing logic in addressedTo is working correctly by
adding tests for it.
2024-09-03 21:58:45 +02:00
Hornwitser 4068129124 Remove useless div on drop zone
This would render on top of modals and doesn't appear to be necessary.
2024-09-03 21:58:45 +02:00
Hornwitser a883e692e4 Add changelog for #619 2024-09-03 21:58:45 +02:00
Hornwitser 5a73a73c36 Fix installer setting bad controller_url
The host.controller_url config is required to end with a / for the url
joining logic to work.  Fix code added #619 setting this to a value not
ending with a /.
2024-09-03 21:58:45 +02:00
Hornwitser a1856e6edf Merge pull request #573 from CCpersonguy/feature/instance-version-2
Display Instance's Factorio version in the web UI
2024-09-03 21:57:42 +02:00
Arvids Godjuks b2f0000a64 Update CHANGELOG.md
Fixed extra new line in changelog
2024-09-03 14:58:38 +03:00
Cooldude2606 8054bd66d2 Fix instance subscriptions (#655)
Remove some assumptions that prevent subscription listeners from working on an
instance.  This means allowing the host to forward the event from the instance
to the controller; correctly storing subscription listeners by address rather
than by connection; and a bunch of network and event stuff to correctly clean up
a listener.

Remove the assumption that all subscriptions events contains the updates
property.  This meant fixing inconsistent types; always checking if the property
existed; fixing lots of the tests that made incorrect assumptions.

Minior fixes:
- Fix double firing of listener callbacks on first connection.
- Fix synced being set to true too early on first connection.
- Fix edge case interaction between last response and last snapshot for values
  with updated set as 0.

Also change the the output made during the start of tests to indicate something
is happening when start up is slow and allow for silent testing to reduce the
amount of text output to the console.
2024-09-03 12:31:32 +02:00
Arvids Godjuks 0c63852a2c Merge branch 'master' into feature/instance-version-2 2024-09-03 02:09:28 +03:00
Hornwitser 47896247fe Add option to set path to server executable (#657)
Allow overriding the logic selecting the path to the Factorio
executable.  This is mainly useful when working with development
versions of the game.
2024-09-03 01:04:50 +02:00
Arvids Godjuks 1687f2f896 Merge pull request #654 from Cooldude2606/feature/plugin-mono-repos
Include mono repo plugins in build
2024-09-03 02:01:04 +03:00
Arvids Godjuks 7e5608e2d8 Merge pull request #656 from clusterio/bugfix/numeric-user-name-as-string
Fix bootstrap command missing type declaration for admin name
2024-09-03 01:31:57 +03:00
Arvids Godjuks 55fcd90ed2 Fix bootstrap command missing type declaration for admin name
Lack of typing was resulting in admin name being serialized to storage as a number and breaking websocket communications protocol due to type mismatch: expected string, got number.

Fixes #536
2024-09-03 01:24:24 +03:00
Cooldude2606 7db5821967 Update change log and publish steps (#646) 2024-09-02 23:06:40 +02:00
Cooldude2606 5215ee8eef Include mono repo plugins in build 2024-09-02 22:01:59 +01:00
Hornwitser 0b50a6a29e Add changelog entry for #652 2024-09-02 22:43:53 +02:00
Arvids Godjuks b69db08414 Merge pull request #652 from Hornwitser/fix-installer-breaking-on-space
Escape arguments when installer invokes commands
2024-09-02 23:18:02 +03:00
Hornwitser 0cd24d120c Install optional dependencies on CI
The @swc/core module depends on optional dependencies being installed in
order to get the correct native binding's package installed.  Remove
--no-optional flag passed to pnpm to install these.
2024-08-30 01:03:54 +02:00
Hornwitser 70cf680edd Escape arguments when installer invokes commands
The usage of shell: true means that no escaping is done on the arguments
passed to the commands invoked by the installer. This leads to the
installation failing if the user for example puts a space in the
username for the admin account.

Fix by escaping the arguments passed on command lines. This is
unreasonably complicated on Windows 😡.
2024-08-30 00:32:58 +02:00
Cooldude2606 660af756e6 Minor Corrections 2024-08-16 07:00:08 +02:00
Cooldude2606 08c522429b Implemented configDir for outDir 2024-08-16 07:00:08 +02:00
Cooldude2606 02df956f6c Fix breaking change introduced in v5.5
https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#simplified-reference-directive-declaration-emit
2024-08-16 07:00:08 +02:00
Cooldude2606 263bb16619 Fix missing codecov upload token 2024-08-16 06:58:14 +02:00
Cooldude2606 1f47f6dfc9 Update all pages to use PageHeader 2024-06-14 19:20:07 +02:00
Danielv123 51657bea44 Extend PageHeader for plugins
My plugins used the antd pageheader more than base did. I did not notice that these features dissappeared when we migrated from antd to our own implementation - this brings them back
2024-06-14 19:19:42 +02:00
Cooldude2606 498638c0c9 Improve config, message, and module templates (#641)
* Improve config, message, and module templates

* Update global handler in template module

* Add comments to empty instance template
2024-06-08 13:53:23 +01:00
Cooldude2606 51c450727d Add clean script to remove dist and other artifacts (#640)
* Add clean script

* Switch to using yargs
2024-06-08 13:53:00 +01:00
Cooldude2606 dbc79779ea Fix sending log messages across invalid link (#639) 2024-06-08 13:52:29 +01:00
Cooldude2606 2c2521a688 Add error tests for sendEvent and sendRequest (#638) 2024-06-07 15:46:49 +01:00
Cooldude2606 70e95a56b9 Fix test starting alt host under windows 2024-06-07 07:28:04 -06:00
Cooldude2606 fa0b0a6c21 Bump ci action versions (#637) 2024-05-26 19:28:27 +01:00
Cooldude2606 8b823a571d Coverage 100% 2024-05-25 18:17:50 +01:00
Cooldude2606 e90451ead8 Fix broken test 2024-05-25 01:20:58 +01:00
Cooldude2606 222a8abdb8 Update change log 2024-05-25 00:32:14 +01:00
Cooldude2606 eb658f86c0 Add unit tests 2024-05-24 22:10:04 +01:00
Cooldude2606 d696b8b52c Change abstract class name 2024-05-24 18:29:08 +01:00
Danielv123 2dbf264d46 Add bun compatibility 2024-05-24 17:25:29 +01:00
Danielv123 ffc1a0fccd Add devcontainer configuration file 2024-05-23 11:54:04 +01:00
Lars Corbijn fddd65ddb8 Update documentation links for typescript change (#633) 2024-05-22 17:52:27 +01:00
Cooldude2606 94a07b5af4 Give restore abilty to memory provider 2024-05-22 17:42:25 +01:00
Cooldude2606 af50cfd47a Consistency changes 2024-05-22 17:19:05 +01:00
Cooldude2606 609db31027 Add hosts, instances, systems 2024-05-22 16:39:22 +01:00
Danielv123 e3ab71a2e9 Fix broken types
Some time between antd 5.15.0 and 5.17.3 they moved around some types, which caused our builds to break. This fixes the issue.
2024-05-21 19:08:15 +01:00
Cooldude2606 509fab358a Add saves datastore 2024-05-19 02:28:36 +01:00
Cooldude2606 6b11fb810a Change mod packs to datastore 2024-05-19 01:41:28 +01:00
Cooldude2606 80b4a96e85 Add datastore to lib 2024-05-19 01:24:30 +01:00
Cooldude2606 f4679ef6d1 Merge pull request #625 from Cooldude2606/fix/loopback-routing
Fix loopback routing message and add tests
2024-05-18 23:04:18 +01:00
Danielv123 eb78cdc322 Allow declaring custom config input components for objects
Input components allow us to customize the config field editing experience in plugins, but due to an oversight this did not include object definitions.
2024-05-18 20:57:46 +01:00
Danielv123 8705377232 Add typebox dependency 2024-05-18 20:57:16 +01:00
Danielv123 692075df1d Add useInstanceConfig hook
Currently the web interface implements configs by pulling the state directly in the components where they are used. I need to access the instance config for a plugin, which would require me to duplicate parts of the instance config retrival code.
Instead, I implement it here under lib. This allows 3rd party consumers to get immediate access if we decide to implement a subscription model for configs in the future.
2024-05-18 20:57:16 +01:00
Cooldude2606 ebc0298115 Update changelog 2024-05-18 18:48:08 +01:00
Cooldude2606 d1fa5e67d4 Improve error throwing for sending events 2024-05-18 18:42:53 +01:00
Daniel 7489a76d4a Merge pull request #602 from Danielv123/react-to-drag-and-drop
React to drag and drop
2024-05-18 17:57:31 +02:00
Cooldude2606 201209efb3 Fix undefined stack on some IPC errors (#624) 2024-05-18 14:56:37 +01:00
Danielv123 c6abe29e89 Correctly disable dropzone when host is offline 2024-05-18 10:27:40 +01:00
Danielv123 c8e54cd563 Fix crash loading mutiplayer map in singleplayer
Caused by on_player_created not being called when
loading a multiplayer map in singleplayer. For
further discussion see $622 and
https://forums.factorio.com/viewtopic.php?f=7&t=113497
2024-05-18 10:59:19 +02:00
Cooldude2606 0c9e91b953 Fix error message on instance loopback attempt 2024-05-18 03:40:18 +01:00
Cooldude2606 fb4a5e40f3 Add tests for loopbacks 2024-05-18 03:33:55 +01:00
Danielv123 05b697fd24 Prevent dropping files outside of dropzones 2024-05-15 19:43:40 +01:00
Daniel d7c2e7eed7 Update mod management section of readme (#618)
* Update mod management section of readme

Resolves #617

* Update section after review
2024-05-15 10:41:24 -06:00
Danielv123 f1fd8247fb Add factorio version indicator feature to changelog 2024-05-15 10:40:36 +01:00
Danielv123 92912fa9e5 Show configured instance version while not running 2024-05-15 10:33:37 +01:00
Danielv123 e3753f292b Show current factorio version on instance page 2024-05-15 09:50:39 +01:00
CCpersonguy b57c419769 Version should disappear first on narrow windows 2024-05-15 09:50:39 +01:00
CCpersonguy 594158deac Remove verion-caching logic in Controller 2024-05-15 09:50:39 +01:00
CCpersonguy 3d3276b02c fix lint errors 2024-05-15 09:50:39 +01:00
CCpersonguy dc2f86dcf3 allow FactorioServer.version to return undefined 2024-05-15 09:50:39 +01:00
CCpersonguy 8a57d9b5f6 display Factorio version in Instances web UI 2024-05-15 09:50:39 +01:00
Cooldude2606 b9e9a754b8 Change all workspace versions to workspace:* (#621) 2024-05-08 20:38:05 +01:00
Cooldude2606 54f56d0ecd Merge pull request #619 from Danielv123/prompt-for-http-port
Installer promt for http port
2024-05-05 20:39:25 +01:00
Danielv123 131e45e10a Skip create integration tests
Tests are skipped because they are really slow and fail in CI
2024-05-05 02:24:57 +02:00
Danielv123 b27256421d Fixed tests 2024-05-05 02:00:16 +02:00
Danielv123 e405f09f33 Add integration tests for create 2024-05-05 01:46:48 +02:00
Danielv123 78a9f73de8 Installer promt for http port
Resolves #564
2024-05-05 00:18:36 +02:00
Danielv123 b6113a1007 Merge branch 'master' into react-to-drag-and-drop 2024-05-04 20:49:29 +02:00
Danielv123 c34a13b3e4 Use global drag and drop hint overlay 2024-05-04 20:31:36 +02:00
Danielv123 920b5c1655 Merge branch 'master' into react-to-drag-and-drop 2024-05-04 15:55:33 +02:00
Hornwitser e341adfb29 Remove redundant if condition
Noticed-By: @Cooldude2606
2024-04-28 20:39:27 +02:00
Danielv123 b70feb37f2 Show table overlay when dragging and dropping saves/mods 2024-02-26 22:04:37 -06:00
Danielv123 ebf6536374 Change style of mod upload button on drag and drop 2024-02-26 22:03:47 -06:00
397 changed files with 32689 additions and 9503 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
{
"image":"mcr.microsoft.com/devcontainers/universal:2",
"postCreateCommand": "mkdir factorio && cd factorio && wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && mv factorio factorio_1.1.107 && rm linux64 && wget https://factorio.com/get-download/2.0.47/headless/linux64 && tar -xf linux64 && mv factorio factorio_2.0.47 && rm linux64 && pnpm install",
}
-1
View File
@@ -1 +0,0 @@
dist/
+22
View File
@@ -0,0 +1,22 @@
<!--
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
-->
### Changelog
```
### Features
- Added doodad to web interface. #999
### Changes
- ...
### Fixes
- ...
### Breaking Changes
- ...
```
+45 -20
View File
@@ -12,41 +12,66 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
# 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]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
with:
version: 8
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- 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 != '18.x' }}
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '18.x' }}
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '18.x' }}
uses: codecov/codecov-action@v2
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 18.x
- run: pnpm i --no-optional --no-frozen-lockfile
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
- name: Upload build to artifacts
uses: actions/upload-artifact@v4
with:
name: clusterio_lib
path: ./dist
if-no-files-found: error
+44
View File
@@ -0,0 +1,44 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.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
with:
files: pnpm-lock.yaml
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.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
with:
files: ./dist/*
+3 -5
View File
@@ -29,15 +29,13 @@ dist/
node_modules
Factorio*
/factorio
/factorio*
/instances
/mods
/database
/plugin-list.json
/config-controller.json
/config-control.json
/config-host.json
/config-*.json
/config-*.json.lock
pnpm-lock.yaml
mod_cache.json
/static/
+324 -1
View File
@@ -13,16 +13,339 @@
### Changes
- Bugfixes and small changes to existing behaviour.
- Small changes to existing behaviour.
### Fixes
- Bugfixes
### Breaking Changes
- Changes which break backwards compatibility with older hosts or plugins code.
### Meta
- Changes to the project activities / structure worth highlighting but have no impact on the published package.
Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.24
### Features
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
### Fixes
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
### Changes
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
### Breaking Changes
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
### Meta
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.23
### Major Features
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
### Features
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
### Breaking Changes
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Daniel](https://github.com/Danielv123)
[@Double](https://github.com/DoubleThoughtTheProgrammer)
[@Hornwitser](https://github.com/Hornwitser)
[@Ryan Walker](https://github.com/solarcloud7)
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
- Added import, export, and restore of user lists (bans / admins / whitelist / combined). [#717](https://github.com/clusterio/clusterio/issues/717)
- Added remote update for core packages and third party plugins. [#761](https://github.com/clusterio/clusterio/issues/761)
- Added config cloning on instance creation. [#720](https://github.com/clusterio/clusterio/issues/720)
### Features
- Added social links and instructions to error page. [#742](https://github.com/clusterio/clusterio/issues/742)
- Added button to download control config. [#740](https://github.com/clusterio/clusterio/issues/740)
- Added server action filter (aka "chat filter") to instance log in web ui. [#744](https://github.com/clusterio/clusterio/issues/744)
- Added `factorio.console_logging` to enable dedicated console log file for servers. [#748](https://github.com/clusterio/clusterio/issues/748)
- Added a recovery mode to host and controller accessible as an option on the run command. [#752](https://github.com/clusterio/clusterio/issues/752)
- Added username search to the users list. [#751](https://github.com/clusterio/clusterio/issues/751)
- Added log level filtering to the web ui. [#745](https://github.com/clusterio/clusterio/issues/745)
- Added pending update detection for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added restart required indicator for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added version mismatch indicator in host list. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added host plugin list command to crl where previously only controller plugin list existed. [#760](https://github.com/clusterio/clusterio/issues/760)
- Added optional feature which allows new plugins to be installed remotely.
- Added bidirectional whitelist syncing. [#716](https://github.com/clusterio/clusterio/issues/716)
- Added support for v2 map exchange strings. [#700](https://github.com/clusterio/clusterio/issues/700)
### Fixes
- Fixed handling of zip files with multiple top-level folders. [#739](https://github.com/clusterio/clusterio/issues/739)
- Fixed looping between instances of ban and admin sync events. [#736](https://github.com/clusterio/clusterio/issues/736)
- Fixed quality being stripped from power armour equipment when moving between servers [#753](https://github.com/clusterio/clusterio/pull/753)
- Resolved webpack type export warnings [#750](https://github.com/clusterio/clusterio/pull/750)
- Reduce page load time on users list by enabling pagination. [#718](https://github.com/clusterio/clusterio/issues/718)
- Corrected stack trace and enabled source mapping during bootstrap. [#764](https://github.com/clusterio/clusterio/pull/764)
- Fixed map gen settings using the incorrect name for cliff settings following exchange string decode.
### Meta
- Silent testing is now truly silent. [#764](https://github.com/clusterio/clusterio/pull/764)
- Node 18 has reached end of life and is no longer maintained, we no longer guarantee support for it.
- CI testing now runs on node 20 & 22 as well as factorio 1.1.110 & 2.0.47.
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.20
### Features
- Update subspace storage to 2.0 and space age. [#733](https://github.com/clusterio/clusterio/pull/733)
- A fresh install of Clusterio will include default mod packs. [#719](https://github.com/clusterio/clusterio/issues/719)
### Fixes
- Fix crash when leaving a server without a logistic requester point [#731](https://github.com/clusterio/clusterio/pull/731)
- Use floor instead of round to prevent "1 minute and 60 seconds" being shown [#723](https://github.com/clusterio/clusterio/issues/723)
- Fixed production statistics overwriting consumption statistics and vise versa [#734](https://github.com/clusterio/clusterio/pulls/734)
- Hot patch to add the updated 2.0.29 control.lua freeplay hash to know scenarios. [commit](https://github.com/clusterio/clusterio/commit/26269df7d4d8f39042c59653008bc8f034a4f3b4)
### Meta
- We have now introduced a new [decisions log](https://github.com/clusterio/clusterio/blob/master/docs/decisions.md) to aid tracking all the technical and design decisions we make and their reasons. [#714](https://github.com/clusterio/clusterio/pull/714)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@psihius](https://github.com/psihius)
## Version 2.0.0-alpha.19
### Major Features
- Added support for 2.0 builtin mods during mod pack creation. [#684](https://github.com/clusterio/clusterio/pull/684)
- Added basic 2.0 support to clusterio lua api and 1st party plugins. [#685](https://github.com/clusterio/clusterio/pull/685)
### Features
- Added Datastore and Datastore Provider classes to the library to support savable Maps. [#629](https://github.com/clusterio/clusterio/pull/629)
- Added clean script to speed up publishing and debugging of build steps. [#640](https://github.com/clusterio/clusterio/pull/640)
- Added sub-tile option on plugin page headers. [#642](https://github.com/clusterio/clusterio/pull/642)
- Greatly improved plugin and module template generation. [#641](https://github.com/clusterio/clusterio/pull/641)
- Duplicate host connections from the same address will kill the older host process. [#647](https://github.com/clusterio/clusterio/pull/647)
- Controller will refuse duplicate host connections from different addresses. [#647](https://github.com/clusterio/clusterio/pull/647)
- Monorepos are now supported for external plugins. [#654](https://github.com/clusterio/clusterio/pull/654)
- Changed default role selection to be a dropdown menu rather than id. [#664](https://github.com/clusterio/clusterio/pull/664)
- Added credential option to config entries to allow writing sensitive data that can't be read back remotely.
- Allow hidden and readonly graphical representations of config values. [#665](https://github.com/clusterio/clusterio/pull/665)
- Added locale exporting for all builtin mods, not just for the base mod. [#684](https://github.com/clusterio/clusterio/pull/684)
- Added option for silent testing which only shows the results, can be ran using `pnpm silent-test` [#680](https://github.com/clusterio/clusterio/pull/680)
- Added prompt for http port on installation of Clusterio. [#564](https://github.com/clusterio/clusterio/issues/564)
- Added drag and drop overlay to upload button on mods page and save table on instance page. [#591](https://github.com/clusterio/clusterio/issues/591)
- Automatically add plugins from ./plugins, ./external_plugins and installed npm modules. [#692](https://github.com/clusterio/clusterio/pull/692)
- `compat.table_to_json` and `compat.json_to_table` supported in all stages in pre 2.0 [#687](https://github.com/clusterio/clusterio/issues/687)
- Add "connect to server" button to web interface, launches with steam api. [#608](https://github.com/clusterio/clusterio/issues/608)
- Add space platform name mapping to space platform statistics. [#706](https://github.com/clusterio/clusterio/pull/706)
- Track space platform speed metric clusterio_statistics_exporter_platform_speed. [#706](https://github.com/clusterio/clusterio/pull/706)
- Track space platform weight metric clusterio_statistics_exporter_platform_weight. [#706](https://github.com/clusterio/clusterio/pull/706)
- Added bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
### Fixes
- Fixed IPC errors reporting undefined when no stack trace was present. [#624](https://github.com/clusterio/clusterio/pull/624)
- Fixed loopback routing for instances reporting the wrong error message. [#625](https://github.com/clusterio/clusterio/pull/625)
- Fixed docs linking to non-existing js files after ts migration. [#633](https://github.com/clusterio/clusterio/pull/633)
- Fixed CI workflows using deprecated version of nodejs. [#637](https://github.com/clusterio/clusterio/pull/637)
- Fixed linux only tests to run on windows. [#661](https://github.com/clusterio/clusterio/pull/661)
- Fixed log events being sent from a host over an invalid websocket. [#639](https://github.com/clusterio/clusterio/pull/639)
- Fixed spaces passed in arguments to the installer causing it to break. [#620](https://github.com/clusterio/clusterio/issues/620)
- Fixed numeric admin name breaking login. [#536](https://github.com/clusterio/clusterio/issues/536)
- Fixed issues with subscriptions which prevented them from working on instances or generic events. [#655](https://github.com/clusterio/clusterio/pull/655).
- Fixed unreliable datastore tests. [#662](https://github.com/clusterio/clusterio/pull/662).
- Fixed 2.0 version extraction from linux headless. downloaded during installation of clusterio. [#671](https://github.com/clusterio/clusterio/pull/671)
- Fixed invalid transient state during server start. [#676](https://github.com/clusterio/clusterio/issues/676)
- Fixed host user sync to instances after reconnecting to the. controller. [#678](https://github.com/clusterio/clusterio/pull/678)
- Fixed WebUI adding and displaying the wrong base mod version. [#684](https://github.com/clusterio/clusterio/pull/684)
- Fixed WebUI inconsistent pattern matching for factorio version. [#684](https://github.com/clusterio/clusterio/pull/684)
- Fixed `safeOutputFile` not being atomic because of a missing path separator. [#659](https://github.com/clusterio/clusterio/pull/659)
- Fixed built-in mods showing as missing in the WebUI. [#581](https://github.com/clusterio/clusterio/issues/581)
- Fixed dirty flag not being set when only the config of an instance is updated. [#675](https://github.com/clusterio/clusterio/pull/675)
- Fixed incorrect command being used to remove players from the whitelist. [#679](https://github.com/clusterio/clusterio/pull/679)
- Fixed CI following undocumented breaking change from downstream dependency. [#683](https://github.com/clusterio/clusterio/pull/683)
- Fixed instance crash when missing saves folder. [#698](https://github.com/clusterio/clusterio/issues/698)
- Fixed statistics exporter not working on newer versions of windows 11 due to wmic deprecation. [#707](https://github.com/clusterio/clusterio/issues/707)
### Changes
- Added prompt for the HTTP port to host the controller to the installer. [#619](https://github.com/clusterio/clusterio/pull/619)
- Added tests for sendEvent and sendRequest throwing the correct error messages. [#638](https://github.com/clusterio/clusterio/pull/638)
- Updated all web ui pages to use correct page header component. [#643](https://github.com/clusterio/clusterio/pull/643)
- Bumped Typescript version to 5.5 and implemented `${configDir}` in base configs for "outDir". [#648](https://github.com/clusterio/clusterio/pull/648).
- Added factorio.executable_path option which allows overriding the default path to the Factorio executable run.
- Added instance version display. [#573](https://github.com/clusterio/clusterio/pull/573)
- Added `factorio.shutdown_timeout` config to set the time the host will wait for a Factorio server to shut down befor killing it.
- Changed shutdown logic to prefer sending a /quit command via RCON instead of using diverging logic on Windows and Linux.
- Added `controller.factorio_username` and `controller.factorio_token` config to set Factorio credentials used for the whole cluster.
- Added `controller.share_factorio_credential_with_hosts` config to optionally require hosts to provide their own credentials.
- Added `host.factorio_username` and `host.factorio_token` config to set Factorio credentials used on a given host.
- Changed `instance.rcon_password` config to be a credential field that can not be read by the control interface.
- Changed `host.id` `instance.id` and `instance.assign_host` config to be hidden fields that can not be seen on the web ui.
- Updated display name and description of `controller.external_address` to avoid confusion. [#674](https://github.com/clusterio/clusterio/pull/674)
- Renamed config value `controller.external_address` to `controller.public_url`, migrations are applied automatically.
- String config values with whitelisted values (enum) now display as a selection dropdown. [#701](https://github.com/clusterio/clusterio/pull/701)
- Existing selection dropdowns now support search filtering. [#701](https://github.com/clusterio/clusterio/pull/701)
- Added surface label to clusterio_statistics_exporter_instance_force_flow_statistics. [#691](https://github.com/clusterio/clusterio/issues/691)
- Compat lib now supports versions from 0.17 to 2.0 [#711](https://github.com/clusterio/clusterio/pull/711)
- Add bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
### Breaking Changes
- Previously silent errors for controller.sendEvent now throw exceptions [#625](https://github.com/clusterio/clusterio/pull/625).
- @clusterio/controller export InstanceInfo has added factorioVersion parameter.
- Argument order for `Config.canAccess` changed to require an access mode passed as the second argument.
- Instances can now have `factorio.enable_script_commands` disabled which will throw an error when any script command is used over rcon. [#681](https://github.com/clusterio/clusterio/pull/681).
- User IDs are now case insensitive, duplicate users will be automatically merged with a backup created. [#682](https://github.com/clusterio/clusterio/pull/682)
- Plugins now require the keyword "clusterio-plugin" in package.json to be automatically loaded [#692](https://github.com/clusterio/clusterio/pull/692)
Many thanks to the following for contributing to this release:
[@CCpersonguy](https://github.com/CCpersonguy)
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
[@Laar](https://github.com/Laar)
[@psihius](https://github.com/psihius)
## Version 2.0.0-alpha.18
### Features
+15 -10
View File
@@ -90,13 +90,13 @@ Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for w
## Installation
Clusterio runs on Node.js v18 and up, it's distributed via npm and comes with a guided install script.
Clusterio runs on all LTS version of NodeJS, it is distributed via npm and comes with a guided install script.
If you already have a recent Node.js installed, you can set it up in a new directory with:
npm init "@clusterio"
Otherwise see below for OS specific instructions.
Otherwise see below for OS specific instructions.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
### Ubuntu setup
@@ -198,7 +198,7 @@ For the standalone installation mode you can use
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriocontroller config set controller.external_address http://203.0.113.4:1234/
npx clusteriocontroller config set controller.public_url http://203.0.113.4:1234/
Change the url to reflect the IP, protocol, and port the controller is accessible under, dns names are also supported.
If you're planning on making the controller accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
@@ -209,12 +209,17 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
## Managing Factorio mods
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a host installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that host if the file doesn't already exist in the `mods` folder for that instance.
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
For managing the mod settings, the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
![Select mod pack for instance](docs/assets/config_select_mod_pack.png)
Better remote management of mods is a planned feature.
Mod settings can be managed under the modpack configuration page in the web UI. For mod settings to show, you first need to run an export. This is done by creating an instance with the modpack assigned, opening the instance page and pressing More -> Export data.
![Export mod configuration data](docs/assets/export_mod_config_data.png)
Once this is done, the mod settings will show up under the modpack configuration page.
![Mod settings configuration in web interface](docs/assets/mod_settings_configuration.png)
## Setting up shared storage
@@ -227,11 +232,11 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation start up the controller and at least one host separately.
After completing the installation and creating your admin user, start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start. You can also use the "Connect via Steam" button to launch Factorio directly and connect to the server using Steam's protocol handler.
### Running via Systemd
+121
View File
@@ -0,0 +1,121 @@
/**
* Clean the repository of build and execution artifacts.
*
* clean: A quick clean that removes all build artifacts making the output safe to publish
* clean-tests: Removes all build and test artifacts should not be required if tests are setup correctly.
* 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");
const npmPackage = require("./package.json");
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
let DRY = false;
/**
* Attempt to remove a file / directory if it exists.
*
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
try {
await fs.access(filePath);
console.log(filePath);
} catch {
return;
}
if (!DRY) {
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
}
}
/**
* Removes all build artifacts
*/
async function removeBuildArtifacts() {
const tasks = [];
for (const workspace of workspaces) {
const basePath = path.resolve(workspace);
const packages = await fs.readdir(basePath);
for (const packageName of packages) {
tasks.push(
tryRemove(path.join(basePath, packageName, "dist"))
);
tasks.push(
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
);
}
}
await Promise.all(tasks);
}
/**
* Removes all test artifacts
*/
async function removeTestArtifacts() {
const artifacts = [
".nyc_output", "coverage", "temp", "static",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Removes all execution artifacts
*/
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-control.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Main function for this script
*/
async function main() {
await yargs(process.argv.slice(2))
.scriptName("clean")
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not delete any files",
default: false,
type: "boolean",
})
.command("all", "Remove all artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
removeExecutionArtifacts();
})
.command("tests", "Remove all build and test artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
})
.command("$0", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "A command must be specified")
.strict()
.parse();
}
// Run main if started from command line
if (module === require.main) {
main();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+1 -1
View File
@@ -30,7 +30,7 @@ There's also the virtual connector which lets two links in the same program be c
## Message
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+1 -1
View File
@@ -6,7 +6,7 @@ Clusterio uses a field based configuration system to manage settings for the con
Plugins can add their own config fields for the controller and instances 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/config/definitions.js](/packages/lib/config/definitions.js) 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` 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.
+10 -18
View File
@@ -51,9 +51,9 @@ interfaces.
Defaults to null.
### controller.external_address
### controller.public_url
External address the controller is accessible on.
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.
This should be a full URL ending with a /.
@@ -182,14 +182,6 @@ You can generate an access token with `clusterioctl host generate-token --id <ho
Defaults to "enter token here".
### host.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### host.public_address
External address instances hosted on this host can be accessed on.
@@ -239,6 +231,14 @@ Does not affect start or stop of the instance while the host is running.
Defaults to false.
### instance.exclude_from_start_all
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
This allows you to prevent certain instances from being started when using the bulk start operation.
Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
@@ -405,14 +405,6 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
+13 -41
View File
@@ -3,6 +3,8 @@
You are probably reading this because you want to contribute code to Clusterio.
Great! We need people like you.
Though we have some conventions and workflows that we kindly ask you to follow.
We have included all the pratical considerations in this document.
Although, we recomend checking out out our [past decisions](/docs/decisions.md) if you have the time.
## Contents
@@ -15,6 +17,7 @@ Though we have some conventions and workflows that we kindly ask you to follow.
- [Editing your Feature Branch](#editing-your-feature-branch)
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
- [Updating Your GitHub Fork](#updating-your-github-fork)
- [Publishing releases to npm](#publishing-releases-to-npm)
- [Changelog](#changelog)
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
@@ -23,7 +26,6 @@ Though we have some conventions and workflows that we kindly ask you to follow.
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Markdown](#markdown)
- [Pushing releases to npm](#pushing-releases-to-npm)
## Workflow
@@ -135,6 +137,8 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
### Editing your Feature Branch
@@ -178,11 +182,15 @@ Fetch the main repository directly as described above and base your feature bran
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
### Publishing releases to npm
For publishing new releases see [package release workflow](./release-workflow.md#packages).
## Changelog
There's a [Changelog](../CHANGELOG.md) in the root of the project.
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
The format of this log should be self explanatory, please follow it carefully.
The format of this log should be self explanatory, please follow it carefully. Please make sure to document any [breaking changes](/docs/decisions.md#breaking-changes).
## Supported Node.js and Factorio Versions
@@ -194,12 +202,14 @@ For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The GitHub Actions tests are run against the latest experimental release.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
## Code Style
The style of the code is a bit of a mixed bag at the moment.
But there are at least a few things that have been agreed upon.
See our [decisions document](/docs/decisions.md) for justifcations.
### Naming style
@@ -284,41 +294,3 @@ And there's two spaces before the next heading.
Headings use the `# heading` format.
```
## Pushing releases to npm
The releases are done by updating packages versions and then publishing them with pnpm.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
```sh
git switch master
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`.
2. Update the version in package files.
Make sure to update the command for the right version jump.
```sh
sed -i 's/2\.0\.0-alpha\.12/2\.0\.0-alpha\.13/' {packages,plugins}/*/package.json
```
3. Inspect, commit and tag the changes.
Again, make sure to be tagging the right version.
```sh
git add -p
git commit -m v2.0.0-alpha.13
git tag v2.0.0-alpha.13
```
4. Publish the packages.
```sh
rm -r ./{plugins,packages}/*/dist
pnpm -r --filter='./{plugins,packages}/*' publish
```
+479
View File
@@ -0,0 +1,479 @@
# Decisions
This document outlines key decisions taken by the project team that should be followed by all maintainers and contributors.
If there is a particular issue or concern you would like the team to comment on then please create a github issue for it.
<!-- Try to use categories that already exist if possible -->
<!-- Sublists should be alphabetical, avoiding starting with "the" -->
<!-- For code style, add (Lang) to section names if present in both -->
1. Documentation
- [Decision Log](#decision-log)
- [TSDoc](#tsdoc)
2. Code Style (General)
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Naming Booleans](#naming-booleans)
- [Naming Files](#naming-files)
- [Naming Times and Durations](#naming-times-and-durations)
- [Strings](#strings)
3. Code Style (TS / JS / Web)
- [ESLint](#eslint)
- [Imports Within `lib` Package](#imports-within-lib-package)
- [Name Style](#name-style-ts)
4. Code Style (Lua)
- [FMTK](#fmtk)
- [Name Style](#name-style-lua)
5. Code Style (Other)
- [Markdown](#markdown)
6. Repository Structure
- [External Plugins](#external-plugins)
- [First Party Plugins](#first-party-plugins)
- [Core Packages](#core-packages)
- [TypeScript Configs](#typescript-configs)
- [Workspace](#workspace)
7. Project Architecture
- [Deprecation of Phin in favour of `fetch`](#deprecation-of-phin-in-favour-of-fetch)
- [Minimal Dependencies](#minimal-dependencies)
- [React](#react)
- [Separation of Host and Controller](#separation-of-host-and-controller)
- [Typescript](#typescript)
- [Webpack](#webpack)
8. Supportability
- [Breaking Changes](#breaking-changes)
- [Distribution](#distribution)
- [Factorio](#factorio)
- [General Support](#general-support)
- [NodeJS](#nodejs)
- [Operating System](#operating-systems)
9. Testing
- [Continuous Integration](#continuous-integration)
- [Continuous Deployment](#continuous-deployment)
- [Mocha](#mocha)
- [Version Matrix](#version-matrix)
> [!note]
> The use of `Time Immemorial` indicates an obsverion of a previously undocumented decision with no known start date, which has remained unchallenged.
<!--
### Template
🧾 0th Month 2000 | Source
> A short sentence describing the action to take in the context of our project.
Write a breif description which includes: the reason a decision was needed, the outcome of the discussion, and the justification of the outcome.
Write using the active voice in third person "we".
Remember to have one sentence per line.
-->
## Documentation
### Decision Log
🧾 22nd December 2024 | Discord Voice Call
> Maintain a document that includes a brief description of the outcomes and the reasoning behind our decisions, along with links to the relevant sources for further reading.
We discovered that we were beginning to ask similar questions to other maintainers that we had previously raised, which led to a waste of time for all involved.
Consequently, we decided that a log of important decisions should be kept for the benefit of ourselves and future maintainers.
### TSDoc
🧾 12th August 2023 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1320573126731370506) | [Github PR #493](https://github.com/clusterio/clusterio/pull/493)
> When a function or it's arguments do not have a clear purpose, a TSDoc comment should be used to describe it.
While TypeScript provides useful type infomation in function hover text, it does not allow for comments about the purpose of functions and their arguments.
Therefore, we decided to use TSDoc comments for this purpose as it is well intergrated in many text editors and IDEs (such as VSCode).
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
API Extractor was also considered but not selected.
## Code Style (General)
### Indenting
🧾 Time Immemorial
> Use tabs for indention. Except for Markdown where spaces carry meaning.
We needed to maintain a consistent whitespace style across all our files.
Therefore, we selected tabs as our standard.
### Line Length
🧾 Time Immemorial
> Lines are limited to 120 characters where tabs count as 4 characters.
We needed to maintain a consistent max line legnth across all our files.
Therefore, we selected 120 characters as our standard.
### Naming Booleans
🧾 25th November 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1310611960508977223)
> Variable and member properties which contain booleans should be named starting with a verb unless it ends with "ed", e.g. `canRestart` `expectEmpty` `hasFallback` `connected` `enabled` `loaded` `banned`
We had inconsistencies in our naming of booleans and aimed to standardise them.
There was little preference for any particular approach, so we chose the one that would require the fewest changes to existing code.
As a result, all boolean values should begin with a verb, unless they end with "ed." For clarity, both "is" and "has" are considered verbs.
### Naming Files
🧾 Time Immemorial
> Files use lowercase_underscore if multiple values are exported. If a single class is exported then it uses the class name in PascalCase.
We needed to maintain a consistent name style across all our files.
Therefore, we selected lowercase undercore for most files, with class files using PascalCase.
### Naming Times and Durations
🧾 18th January 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1197566542548828170)
> Variable and member properties which contain times or durations should end with their SI unit of messure, e.g. `updatedAtMs`
We found verifying the units of measurement used in different locations to be a simple yet repetitive task that could lead to errors.
Our main concern was the mixing of variables containing seconds and milliseconds.
Therefore, we decided that times and durations should explicitly include their units, whereas other value types do not require any units.
### Strings
🧾 Time Immemorial
> Strings should use double quotes `"` unless the string contains them, in which case single quotes `'` can be used. For JS string templates use backticks ``` ` ``` as normal.
We needed to maintain a consistent string style across all our files.
Therefore, we selected double quotes as our standard.
## Code Style (TS / JS / Web)
### ESLint
🧾 Time Immemorial
> Use ESLint for style checking.
We needed to maintain a consistent style across all our files.
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
### Imports Within `lib` Package
🧾 Time Immemorial
> When importing files within the lib package, all names should be prefixed with "lib".
The exports of the various library files are often very generic and may conflict with variable names.
Therefore, when importing these files the "lib" prefix should be used to avoid confussing.
This only applies to files within `lib` importing other files in `lib` because all other packages import the whole of `lib` as a single package.
### Name Style (TS)
🧾 Time Immemorial
> Variables and members of classes use camelCase. Classes use PascalCase. Config values use lowercase_underscore. Exceptions allowed for over the write objects.
We needed to maintain a consistent name style across all our files.
Therefore, we follow common practive for JavaScript of using camelCase and PascalCase.
Although, we have also allowed the use of lowercase_underscore for config values to distinguish them from other variables.
## Code Style (Lua)
### FMTK
🧾 14th June 2024 | [External PR](https://github.com/justarandomgeek/vscode-factoriomod-debug/pull/126)
> Use of FMTK is not required but is advised.
> To enable Clusterio support, use the CLI flag `--clusterio-modules` under `Lua.runtime.pluginArgs`.
We needed to maintain a consistent style across all our files.
There are multiple competing standards for Lua code, and given the limited amount of Lua code in this project, we chose not to adopt any automatic linting or shared luarc file.
Nevertheless, we recomend the use of [FMTK](https://github.com/justarandomgeek/vscode-factoriomod-debug) and have taken steps towords supporting it in Clusterio.
### Name Style (Lua)
🧾 Time Immemorial
> Everything uses lowercase_underscore
We needed to maintain a consistent name style across all our files.
Therefore, we follow common practive for Lua of using lowercase_underscore.
## Code Style (Other)
### Markdown
🧾 Time Immemorial
> Use one sentence per line.
> Place two lines before each heading, except for the first one or when two headings are adjacent.
We needed to maintain a consistent code style across all our markdown files.
Therefore, we selected an approach which makes diff reports more useful and which makes headings use additional space to make them stand out.
## Repository Structure
### External Plugins
🧾 Time Immemorial
> No external plugins should be included in the main repository.
> A folder will exist which external plugin repositories can be cloned into and will be given the same treatment as first party plugins.
We require a designated space for developers to create their plugins without the concern of conflicts with first-party plugins.
To address this, we have introduced a dedicated folder for external plugins.
This solution is straightforward and has since proven to be highly effective.
### First Party Plugins
🧾 Time Immemorial
> First party plugins are included in the main repository.
> Those selected to be first party are held to the same stanards which apply to the core packages.
> Each will recieve a new release in step with the core packages.
We require a designated space for first-party plugins, separate from the core packages, to clearly represent the dependency between them.
Creating a folder provided a simple solution to this.
### Core Packages
🧾 Time Immemorial
> All core packages will be contained in the packages folder of the repository.
> They are allowed to be interdependent on each other.
We require a set of core packages to represent the different applications used in a cluster.
See [Separation of Host and Controller](#separation-of-host-and-controller).
To avoid putting more files into the repository root we created a folder for the core packages.
### TypeScript Configs
🧾 26th August 2023 | [Github PR #504](https://github.com/clusterio/clusterio/pull/504)
> Each package / plugin may contain any of: `tsconfig.json` `tsconfig.node.json` and `tsconfig.web.json`.
> The main respository root will contain all three files in addition to `tsconfig.base.json` which node and web extend from.
> The configs inside packages / plugins should extend from those in the respository root.
We found that maintaining independent tsconfig files became burdensome and required a way to consolidate them.
Initially, we introduced a base configuration file; however, this proved insufficient, so we extended it to include separate configurations for Node.js and the Web.
This approach led to each package having up to three tsconfig files.
Nevertheless, we believe this to be justified, given the need to differentiate between Node.js and Web builds while maintaining a single, shared configuration.
### Workspace
🧾 Time Immemorial
> The workspace root should contain only: configuration files, scripts used to build / clean the workspace, the readme, and the changelog.
We need to maintain a clean workspace root by only including files which effect all pacakges and plugins such as configuration files.
We have allowed for build and clean scripts to exist here as long as they are not published as part of any package or plugin.
## Project Architecture
### Deprecation of Phin in favour of `fetch`
🧾 12th December 2024 | [Discord Discussion Dec 24](https://discord.com/channels/450361298220351489/450361298220351491/1316781609206419567) | [Discord Discussion May 25](https://discord.com/channels/450361298220351489/450361298220351491/1240246997496102942)
> The builtin `fetch` method should be used as a replacement of the deprecated Phin module.
We required a replacement for the deprecated Phin HTTP client, which was used for making server-side requests and as part of testing.
With `fetch` being marked as stable in Node.js v21.0.0 and having first been made available in v16.15.0, it serves as a suitable replacement without the need to add new dependencies.
It had previously not been used because it was not marked as stable in any LTS version of NodeJS.
### Minimal Dependencies
🧾 Time Immemorial
> Any new dependencies need to be well justified.
We aim to minimise our exposure to upstream changes by using a minimal number of dependencies.
This also serves to reduce the bloat of a cluster installation and improve installation times.
Consequently, the addition of any new dependencies must be thoroughly justified, with evidence of research into alternative options and comparisons between them.
### React
🧾 Time Immemorial
> Our web stack uses ReactJS
We require a reactive website for our control panel, and ReactJS was selected due to its widespread use and familiarity.
Switching away from React for any reason would represent a significant undertaking.
### Separation of Host and Controller
🧾 Time Immemorial
> The primary application locations are Control (web and cli), Controller, and Host.
> Therefore the core packages are: `ctl` `controller` `host`. There are two libaray packages `lib` and `web_ui`.
> Additionally `create` exists to provide easy creation of a cluster.
A single application to perform all the duties of a cluster would complicate the reasoning and maintenance of the project, so we decided to split it into several packages.
The primary components of a cluster include servers connected to an instance, which runs on a host that connects to the controller, whose configuration is managed via a control connection from a CLI or web portal.
Based on this structure, we found it logical to divide the packages according to their installation location, resulting in `ctl`, `controller`, and `host`, with shared code separated into `lib` and `web_ui` depending on where it is utilised.
The additional `create` package is used by `npm create` to provide a user-friendly installation process.
### TypeScript
🧾 6th August 2023 | [Github PR #491](https://github.com/clusterio/clusterio/issues/491)
> We use TypeScript as our language of choice.
We previously used JavaScript and switched to TypeScript to provide a robust type system.
Transitioning to any other langauge would be too significant an undertaking.
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
### Webpack
🧾 Time Immemorial
> Our web stack uses Webpack and associated plugins.
We required a bundler for our web build, and at the time, Webpack was the standard.
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
## Supportability
### Breaking Changes
🧾 Time Immemorial
> 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.
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.
### Distribution
🧾 Time Immemorial
> We use `git` and `pnpm` for development distribution.
> We use `npm` and the factorio mod portal for production distribution.
We need to distribute both production builds and development source code for the project.
By necessity, we use `npm` and the Factorio Mod Portal to distribute our production builds.
For development, we rely on `git` and GitHub, which are industry standards, while using `pnpm` as a more efficient variant of `npm` for development installations.
Although bun was considered upon its release, we were not convinced it was the right choice.
### Factorio
🧾 26th October 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1299637042350653461)
> We actively provide full support for the last two major versions of Factorio.
> This approach does not follow semantic versioning; instead, it follows the "middle" versions: 2.1 > 2.0 > 1.1 > 1.0 > 0.18 > 0.17.
> We offer passive support for all versions from 0.17 onwards, where implementation is trivial and does not impose restrictions on our actively supported versions.
Following the 2.0 update of Factorio, which introduced a large number of breaking changes, we needed to reconsider which versions would be actively supported, as it was no longer trivial to support all versions.
Anticipating that future breaking changes would also be significant, and assuming the majority of players would update to the latest stable version upon release, we decided to support the latest stable version and the previous major version that introduced breaking changes.
Support for other versions will not be removed if they have already been implemented and do not impose restrictions on our ability to support newer versions.
### General Support
🧾 Time Immemorial
> We provide full support via GitHub and our Discord server for the latest published version of Clusterio, as well as assistance with migrating from the previous published version.
> However, we do not offer any guarantees with our support; we assist when time permits and may not be able to resolve every issue.
> Additionally, while we offer support in setting up development workspaces and guidance for plugin development, we do not provide support for those running development builds as production instances.
We aim to support as many users of Clusterio as possible in setting up and using the platform for a variety of purposes, including setting up production instances, development workspaces, and providing guidance on plugin development.
However, our time to work on Clusterio is limited, and even more so our ability to offer support.
As such, we cannot assist with every issue and must set reasonable restrictions to ensure expectations are aligned.
Consequently, we do not offer any guarantees for our support, but we strive to provide as much assistance as possible for those setting up or running the latest published version.
Additionally, we support users in setting up development workspaces, as the more people willing to contribute, the better.
### NodeJS
🧾 Time Immemorial
> We support all LTS versions of NodeJS in their active and maintenance phases.
Our options were: supporting all versions, supporting all active versions, supporting all LTS versions, or supporting only the latest version.
As we aim to provide a stable, production-ready tool while still limiting the number of versions we need to manage, we chose to support all LTS versions because NodeJS can be installed by the majority of linux distro package managers which will always offer at least one LTS version.
### Operating Systems
🧾 Time Immemorial
> We support all major versions of Windows, Linux, and MacOS.
This is largely inpart to our use of NodeJS and is not a guarantee from us.
Our choice of Node.js has enabled us to support all major operating systems with minimal effort on our part.
Nevertheless, there have been instances where code paths have diverged between systems, and we have attempted to maintain support for as many systems as possible.
However, this is not a priority, and such support has often been reactive or provided by external contributors.
## Testing
### Continuous Integration
🧾 Time Immemorial
> We run unit and integration tests through GitHub CI actions as well as test coverage through CodeCov.
Continuous integration actions in GitHub are a valuable tool that we use to assist with the review of pull requests from both maintainers and contributors.
While we aim to increase the number of useful checks performed by CI in the future, this is not a priority over the development of new features.
### Continuous Deployment
🧾 Time Immemorial
> We do not use any CD actions, and have no plans of implementing them.
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
This is due to the frequent breaking changes merged into the main branch, which we prefer to bundle together as a single version.
In the future, we may automate the deployment process, but it will always require manual activation.
### Mocha
🧾 Time Immemorial
> We use Mocha for testing and CodeCov for coverage.
We chose Mocha because it was a good fit when we selected it.
However, we are always looking for a better framework which is worth the effort of switching.
### Version Matrix
🧾 Time Immemorial
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
We perform tests on all versions of NodeJS and Factorio that we support.
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
+1 -1
View File
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
## Communicating with Clusterio
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [socket.md](socket.md) for the implementation details of it.
See [protocol.md](devs/protocol.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
+3 -3
View File
@@ -1,7 +1,7 @@
# Protocol
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
Two general kinds of messages are supported: events and requests.
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
@@ -166,7 +166,7 @@ Events are messages send in one direction over the link, that the receiver is ex
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
Plugins may defined their own events as well.
#### `*_event`
@@ -181,7 +181,7 @@ They function a lot like HTTP requests except that both parties of a connection
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
Plugins may define their own requests as well.
#### `*_request`
+62
View File
@@ -28,6 +28,21 @@ Generate a token for a host. `--id` is the host id.
Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout.
### Update Clusterio
ctl> host update [--id <host id>] [--name <host name>] [--restart]
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete.
Remote updates are enabled by default but can be disabled 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 following command locally while the host is offline:
host> config set host.allow_remote_updates true/false
The controller also supports remote updates, following the same logic. The corresponding commands are:
ctl> controller update [--restart]
controller> config set controller.allow_remote_updates true/false
## Instances
### List Instances
@@ -199,3 +214,50 @@ Note: If the player joins the cluster again a new account will be made for them
## Roles
To be written.
## Plugins (Local)
To be written.
## Plugins (Remote)
By default, remote updates are enabled unless you selected "no" during installation.
Remote updates allow ctl and the web UI to update plugins on machines within the cluster.
However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature.
### List
ctl> controller plugin list
ctl> host plugin list <host>
This command lists all plugins currently known to the remote. Newly installed plugins will not be displayed until the remote is restarted. Plugins that are distributed as an npm package can be updated remotely.
### Update
ctl> controller plugin update <name> [--restart]
ctl> host plugin update <host> <name> [--restart]
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.
### Install
ctl> controller plugin install <name> [--restart]
ctl> host plugin install <host> <name> [--restart]
If remote plugin installs are enabled on the remote, this will execute `npm install --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.
### Enable/Disable Updates
host> config set host.allow_plugin_updates true/false
controller> config set controller.allow_plugin_updates true/false
Remote updates are enabled by default but can be disabled 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.
### Enable/Disable Installs
host> config set host.allow_plugin_install true/false
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.
+4
View File
@@ -10,6 +10,10 @@ You can do this by running the follow RCON command on an instance that is curren
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
If you for some reason want to run this function in 2.0+, use the following command:
/sc local a={name="pack",description="",factorio_version=script.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}}for b,c in pairs(script.active_mods)do table.insert(a.mods,{name=b,enabled=true,version=c})end;local function d(e,f)for b,c in pairs(e)do a.settings[f][b]=c end end;d(settings.startup,"startup")d(settings.global,"runtime-global")if game.player then d(game.player.mod_settings,"runtime-per-user")else d(settings.player,"runtime-per-user")end;local g=helpers.encode_string(helpers.table_to_json(a))log(g)rcon.print(g)
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
Mod Pack strings looks something like this:
+69
View File
@@ -0,0 +1,69 @@
# Release workflow
## Update changelog
- CHANGELOG.md in the repository root
- The changelog.txt file in mod directories
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
## Packages
> [!NOTE]
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. Ensure you are on the latest version of master branch with no untracked files
```sh
git switch master
git pull --ff-only upstream master
git status
```
2. Bump the version in the `package.json` files
```sh
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
````
3. Tag and push the release to github
```sh
git add -p
git commit -m v2.0.0-alpha.XX
git tag v2.0.0-alpha.XX
```
4. Perform a clean install and publish the packages.
```sh
pnpm clean
pnpm -r --filter='./{plugins,packages}/*' publish
```
## Plugins
1. Bump the version in the `package.json` file
2. Run `pnpm install` in the repository root
3. Run `pnpm adduser` to authenticate
4. Run `pnpm publish` in the plugin/package directory
Before confirming the publish, ensure the packed size is approximately the same size as the last version (or within the expected size)
After publishing, test creating and installing the plugin on a fresh install.
## Lua mods
To publish lua mods like clusterio_lib, start by building from source:
```sh
pnpm run build-mod
```
or download the latest artifacts from the CI. When uploading the mods to the mod portal, make sure the latest version is the last version uploaded, as the game/mod portal/ingame updater isn't consistent in how it figures out the "latest version"
## Release notes
Release notes are expected to be published alongside new releases. We use the github releases system as well as posting in the #announcements channel on the discord. Major releases may warrant a ping.
+19 -4
View File
@@ -63,10 +63,25 @@ This creates a private key file named `key.pem` and a public certificate file na
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
npx clusteriohost config set host.tls_ca cert.pem
**For run-host.cmd**
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
npx clusterioctl control-config set control.tls_ca cert.pem
**For run-host.sh**
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
**For clusteriohost.service**
Environment=NODE_EXTRA_CA_CERTS=path/to/cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
# On Windows
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
# On Linux
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
# You can now use clusterioctl
npx clusterioctl instance list
+276 -185
View File
@@ -1,116 +1,47 @@
"use strict";
module.exports = {
"plugins": [
"@typescript-eslint",
"node",
],
"root": true,
"env": {
"node": true,
"commonjs": true,
"es2022": true,
import {
defineConfig,
globalIgnores,
} from "eslint/config";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import node from "eslint-plugin-node";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import {
FlatCompat,
} from "@eslint/eslintrc";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([{
plugins: {
"@typescript-eslint": typescriptEslint,
"@stylistic": stylistic,
node,
},
"overrides": [
{
"files": ["{packages,plugins}/**/*.ts?(x)"],
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"prefer-const": "off",
},
languageOptions: {
globals: {
...globals.node,
...globals.commonjs,
},
{
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
"env": {
"mocha": "true",
},
"parserOptions": { sourceType: "commonjs" },
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"valid-jsdoc": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
"files": [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
"env": {
"browser": true,
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
{
"files": [
"packages/create/templates/**/*.{t,j}s?(x)",
],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
},
],
},
"rules": {
"@stylistic/indent": ["error", "tab", {
"ignoredNodes": [
"JSXFragment", // TODO: Linter crashes if this is removed
],
"ignoreComments": true,
"SwitchCase": 1, // Indent case statements one level from switch
}],
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": ["error", "never"],
@@ -118,26 +49,34 @@ module.exports = {
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "off",
"arrow-spacing": ["error", { "after": true, "before": true }],
"arrow-spacing": ["error", {
"after": true,
"before": true,
}],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"brace-style": ["error", "1tbs", {
"allowSingleLine": true,
}],
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
},
],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
}],
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": "error",
"complexity": ["error", { "max": 25 }],
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
@@ -146,27 +85,36 @@ module.exports = {
"default-case-last": "error",
"default-param-last": "off",
"dot-location": ["error", "property"],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"dot-notation": ["error", {
"allowPattern": "^[a-z]+(_[a-z]+)*$",
}],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration", {
// Allow arrow functions as it better expresses referencing this
// of the containing scope, which is unweilding in plain functions.
"allowArrowFunctions": true,
}],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"generator-star-spacing": ["error", {
"before": false,
"after": true,
}],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent": ["error", "tab", { "SwitchCase": 1 }],
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
@@ -176,10 +124,18 @@ module.exports = {
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lines-between-class-members": ["error", "always", {
"exceptAfterSingleLine": true,
}],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": ["error", { "code": 120 }],
"max-len": ["error", {
"code": 120,
}],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
@@ -188,10 +144,12 @@ module.exports = {
"max-statements-per-line": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum"],
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
@@ -205,7 +163,11 @@ module.exports = {
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-console": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-constant-condition": ["error", {
"checkLoops": false,
}],
"no-constructor-return": "error",
"no-continue": "off",
"no-div-regex": "error",
@@ -230,16 +192,15 @@ module.exports = {
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-mixed-operators": [
"error",
{
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
},
],
"no-mixed-operators": ["error", {
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
}],
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -254,7 +215,11 @@ module.exports = {
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-plusplus": ["error", {
"allowForLoopAfterthoughts": true,
}],
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
@@ -266,9 +231,15 @@ module.exports = {
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
"no-spaced-func": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
@@ -287,7 +258,11 @@ module.exports = {
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": ["error", { "allowAsStatement": true }],
"no-void": ["error", {
"allowAsStatement": true,
}],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
@@ -297,6 +272,7 @@ module.exports = {
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": ["error", "after", {
"overrides": {
"?": "before",
@@ -306,6 +282,7 @@ module.exports = {
"??": "before",
},
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
@@ -322,9 +299,13 @@ module.exports = {
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", { "avoidEscape": true }],
"quotes": ["error", "double", {
"avoidEscape": true,
}],
"radix": ["error", "always"],
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
"require-atomic-updates": "off",
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
@@ -340,73 +321,183 @@ module.exports = {
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["error", "always", {
"line": {
"markers": ["/"],
},
}],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", {
"requireReturn": false,
"requireReturnType": false,
"requireParamType": false,
}],
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
"yoda": ["error", "never", {
"exceptRange": true,
}],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
// Node.js callback passing style of (err, value) is not used in this project
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": ["error", {
"tryExtensions": [".js", ".ts"],
}],
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
},
}],
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
};
}, {
files: ["{packages,plugins}/**/*.ts?(x)"],
languageOptions: {
parser: tsParser,
},
extends: compat.extends("plugin:@typescript-eslint/recommended"),
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "off",
"no-shadow": "off",
},
}, {
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
languageOptions: {
globals: {
...globals.mocha,
},
sourceType: "commonjs",
parserOptions: {},
},
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
}, {
files: [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
languageOptions: {
globals: {
...globals.browser,
},
"sourceType": "module",
parserOptions: {
"ecmaFeatures": {
"jsx": true,
},
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"@stylistic/indent": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
}, globalIgnores(["**/dist/", "dist/"])]);
+32 -16
View File
@@ -1,16 +1,21 @@
{
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"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",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -21,27 +26,38 @@
]
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"workspaces": [
"packages/*",
"plugins/*",
"external_plugins/*",
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@swc/core": "^1.4.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.45.0",
"@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",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.18.2",
"form-data": "^4.0.0",
"fs-extra": "^11.1.1",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.0",
"mocha": "^10.2.0",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.24.8",
"typescript": "^5.1.6"
"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"
}
}
+87 -53
View File
@@ -15,7 +15,7 @@
*/
import path from "path";
import fs from "fs-extra";
import fs from "node:fs/promises";
import crypto from "crypto";
import setBlocking from "set-blocking";
@@ -32,6 +32,7 @@ const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
// globals
@@ -66,9 +67,9 @@ void new lib.Gauge(
if (!controller || !controller.hosts) {
return;
}
for (let [id, host] of controller.hosts) {
for (const host of controller.hosts.values()) {
gauge.labels({
host_id: String(id),
host_id: String(host.id),
host_name: host.name,
}).set(1);
};
@@ -86,9 +87,9 @@ void new lib.Gauge(
if (!controller || !controller.instances) {
return;
}
for (let [id, instance] of controller.instances) {
for (const instance of controller.instances.values()) {
gauge.labels({
instance_id: String(id),
instance_id: String(instance.id),
instance_name: String(instance.config.get("instance.name")),
host_id: String(instance.config.get("instance.assigned_host")),
}).set(1);
@@ -127,47 +128,66 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
): Promise<void> {
let subCommand = args._[1];
let userManager = new UserManager(controllerConfig);
await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
const databaseDirectory = controllerConfig.get("controller.database_directory");
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
).bootstrap());
const userManager = new UserManager(users, roles, controllerConfig);
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
let admin = userManager.users.get(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
const user = userManager.getOrCreateUser(args.name);
user.roleIds.add(lib.Role.DefaultAdminRoleId);
user.isAdmin = true;
user.saveRecord();
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await userManager.records.save();
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
let user = userManager.users.get(args.name);
let user = userManager.getByName(args.name);
if (!user) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(userManager.signUserToken(user.name));
console.log(userManager.signUserToken(user));
} else if (subCommand === "generate-host-token") {
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
));
} else if (subCommand === "create-ctl-config") {
let admin = userManager.users.get(args.name);
let admin = userManager.getByName(args.name);
if (!admin) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
@@ -178,7 +198,7 @@ async function handleBootstrapCommand(
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
userManager.signUserToken(admin.name),
userManager.signUserToken(admin),
);
let content = JSON.stringify(controlConfig, null, "\t");
@@ -210,7 +230,6 @@ interface InitializeParameters {
shouldRun: boolean;
clusterLogger: winston.Logger;
pluginInfos: lib.PluginNodeEnvInfo[] | null;
controllerConfigPath: string | null;
controllerConfig: lib.ControllerConfig | null;
}
@@ -244,12 +263,17 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
yargs
.command("create-admin <name>", "Create a cluster admin")
.command("generate-user-token <name>", "Generate authentication token for the given user")
.command("create-admin <name>", "Create a cluster admin", yargs => {
yargs.positional("name", { describe: "Name of the admin user", type: "string" });
})
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
yargs.positional("name", { describe: "Name of the user", type: "string" });
})
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
yargs.positional("id", { describe: "ID of the host", type: "number" });
})
@@ -266,6 +290,11 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart the controller on failure",
});
yargs.option("recovery", {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -309,18 +338,14 @@ async function initialize(): Promise<InitializeParameters> {
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
shouldRun = true;
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, { encoding: "utf8" })));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -330,7 +355,6 @@ async function initialize(): Promise<InitializeParameters> {
shouldRun,
clusterLogger,
pluginInfos: null,
controllerConfigPath: null,
controllerConfig: null,
};
}
@@ -341,46 +365,49 @@ async function initialize(): Promise<InitializeParameters> {
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
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
let controllerConfig = new lib.ControllerConfig("controller");
try {
let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" }));
controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller");
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");
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
}
}
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
controllerConfig.set("controller.auth_secret", bytes.toString("base64"));
await lib.safeOutputFile(
controllerConfigPath, JSON.stringify(controllerConfig, null, "\t")
);
await controllerConfig.save();
}
if (command === "config") {
await lib.handleConfigCommand(
args, controllerConfig, controllerConfigPath
);
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
return {
args,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
shouldRun,
};
@@ -396,28 +423,35 @@ async function startup() {
shouldRun,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
} = await initialize();
if (!shouldRun || !pluginInfos || !controllerConfigPath || !controllerConfig) {
if (!shouldRun || !pluginInfos || !controllerConfig) {
return;
}
controller = new Controller(
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.users.records.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -430,7 +464,7 @@ async function startup() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -457,7 +491,7 @@ I version of clusterio. Expect things to break. I
`
);
startup().catch(err => {
if (err instanceof lib.StartupError) {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
+5 -2
View File
@@ -1,12 +1,15 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceInfo } from "./src/InstanceInfo";
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";
if (module === require.main) {
+31 -32
View File
@@ -3,58 +3,57 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.18",
"version": "2.0.0-alpha.24",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriocontroller": "./dist/node/controller.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.18",
"@sinclair/typebox": "^0.30.4",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.7.4",
"express": "^4.18.2",
"finalhandler": "^1.2.0",
"fs-extra": "^11.1.1",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"webpack-cli": "^5.1.4",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.13.0",
"compression": "^1.8.0",
"express": "catalog:",
"finalhandler": "^1.3.1",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.18",
"@swc/core": "^1.4.0",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.14",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"@types/express": "catalog:",
"@types/finalhandler": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
+13 -6
View File
@@ -1,5 +1,5 @@
import path from "path";
import fs from "fs-extra";
import fs from "node:fs/promises";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
@@ -16,7 +16,7 @@ import * as routes from "./routes";
* @alias module:controller/src/BaseConnection
*/
export default class BaseConnection extends lib.Link {
declare ["connector"]: WsServerConnector;
declare "connector": WsServerConnector;
constructor(
connector: lib.WebSocketBaseConnector,
@@ -32,16 +32,17 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
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.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
this._controller.subscriptions.unsubscribe(this);
this._controller.subscriptions.unsubscribeLink(this);
});
}
async disconnect(code: number, reason: string) {
try {
await this.connector.disconnect();
await this.connector.disconnect(code, reason);
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
@@ -78,7 +79,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: string, sha1?: string }) {
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -93,10 +94,12 @@ export default class BaseConnection extends lib.Link {
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
// Open source file before creating the stream in case it throws.
let source = (await fs.open(modPath)).createReadStream();
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = fs.createReadStream(modPath);
stream.source = source;
stream.mime = "application/zip";
stream.size = String(mod.size);
@@ -106,4 +109,8 @@ export default class BaseConnection extends lib.Link {
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
@@ -3,7 +3,7 @@ import type {
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceInfo from "./InstanceInfo";
import type InstanceRecord from "./InstanceRecord";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
@@ -91,7 +91,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -117,7 +117,7 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -261,7 +261,7 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
+590 -68
View File
@@ -6,7 +6,8 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -40,7 +41,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: ControllerUser, // The user making this connection.
public user: User, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -70,7 +71,9 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.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));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
@@ -82,6 +85,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
@@ -98,6 +102,9 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this));
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -116,6 +123,11 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this));
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this));
this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -134,7 +146,7 @@ export default class ControlConnection extends BaseConnection {
this.checkPermission(message, entry);
} catch (err: any) {
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`);
logger.audit(`Permission denied for ${message.name} by ${this.user.id} from ${this.connector.dst}`);
throw err;
}
}
@@ -185,6 +197,26 @@ export default class ControlConnection extends BaseConnection {
return this._controller.config.toRemote("control");
}
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this._controller.config.stageProp(
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
);
}
} else {
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
}
}
this._controller.config.commitStaging();
} finally {
this._controller.config.revertStaging();
}
}
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
}
@@ -195,7 +227,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
const host = this._controller.hosts.get(request.hostId);
const host = this._controller.hosts.getMutable(request.hostId);
if (!host) {
throw new Error(`Unknown host id (${request.hostId})`);
}
@@ -207,7 +239,7 @@ export default class ControlConnection extends BaseConnection {
hostConnection.connector.terminate();
}
this._controller.hostsUpdated([host]);
this._controller.hosts.set(host);
}
async handleHostListRequest(): Promise<lib.HostDetails[]> {
@@ -240,7 +272,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
@@ -249,43 +281,77 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
const instanceConfig = new lib.InstanceConfig("controller");
if (request.cloneFromId) {
const baseInstance = this._controller.instances.get(request.cloneFromId);
if (!baseInstance) {
throw new lib.RequestError(`Instance with ID ${request.cloneFromId} does not exist`);
}
instanceConfig.update(baseInstance.config.toJSON(), false);
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instanceCreate(instanceConfig);
await this._controller.instances.createInstance(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instanceDelete(request.instanceId);
await this._controller.instances.deleteInstance(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
if (request.field === "instance.assigned_host") {
private static validateInstanceConfigSetField(fieldName: string) {
if (fieldName === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
if (request.field === "instance.id") {
if (fieldName === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new lib.RequestError("Setting instance.id is not supported");
}
}
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
ControlConnection.validateInstanceConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
instance.config.stageProp(
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
);
}
} else {
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
}
}
instance.config.commitStaging();
} finally {
instance.config.revertStaging();
}
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
ControlConnection.validateInstanceConfigSetField(request.field);
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(request.instanceId);
let { field, prop, value } = request;
ControlConnection.validateInstanceConfigSetField(field);
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
await this._controller.instanceAssign(request.instanceId, request.hostId);
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
}
async handleInstanceSaveDetailsListRequest() {
@@ -297,7 +363,7 @@ export default class ControlConnection extends BaseConnection {
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = name;
let ready = new Promise((resolve, reject) => {
let ready = new Promise<void>((resolve, reject) => {
stream.events.on("source", resolve);
stream.events.on("timeout", () => reject(
new lib.RequestError("Timed out establishing stream from host")
@@ -319,8 +385,8 @@ export default class ControlConnection extends BaseConnection {
if (request.sourceInstanceId === request.targetInstanceId) {
throw new lib.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
@@ -397,8 +463,7 @@ export default class ControlConnection extends BaseConnection {
if (this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.set(modPack);
}
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
@@ -406,19 +471,16 @@ export default class ControlConnection extends BaseConnection {
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.set(modPack);
}
async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) {
let { id } = request;
let modPack = this._controller.modPacks.get(id);
let modPack = this._controller.modPacks.getMutable(id);
if (!modPack) {
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
}
modPack.isDeleted = true;
this._controller.modPacks.delete(id);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.delete(modPack);
}
async handleModGetRequest(request: lib.ModGetRequest) {
@@ -518,6 +580,241 @@ export default class ControlConnection extends BaseConnection {
};
}
/**
* Handle request to fetch all mods from the Factorio Mod Portal for a given version.
* @param request - The request object containing factorioVersion.
*/
async handleModPortalGetAllRequest(request: lib.ModPortalGetAllRequest) {
const cacheKey = `${request.factorioVersion}-${request.hide_deprecated}`;
const cachedData = this._controller.modPortalCache.get(cacheKey);
const cacheDuration = this._controller.config.get("controller.mod_portal_cache_duration_minutes") * 60 * 1000;
if (cachedData && Date.now() - cachedData.timestamp < cacheDuration) {
return new lib.ModPortalGetAllRequest.Response(cachedData.data);
}
try {
logger.info(`Fetching mod portal data for ${cacheKey}`);
const mods = await lib.ModStore.fetchAllModsFromPortal(
request.factorioVersion,
this._controller.config.get("controller.mod_portal_page_size"),
request.hide_deprecated
);
this._controller.modPortalCache.set(cacheKey, { timestamp: Date.now(), data: mods });
// The Response class is defined inline within the Request class
return new lib.ModPortalGetAllRequest.Response(mods);
} catch (error: any) {
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
// Propagate a user-friendly error back to the client
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
}
}
/**
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
const factorioVersion = request.factorioVersion;
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
if (toDownload.length > 0) {
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || username === "" || !token || token === "") {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
} catch (error: any) {
logger.error(`Error downloading mods from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = "Mod portal download failed";
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
// Verify all mods are present
const modInfos = [];
const installedMods = [...this._controller.modStore.mods()];
for (const mod of request.mods) {
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
if (!modInfo) {
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
}
modInfos.push(modInfo);
}
return modInfos;
}
// eslint-disable-next-line complexity
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const skipSearch = new Set(builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = 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>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
let releases = _modPortalReleases.get(modName);
if (releases === undefined) {
try {
releases = await lib.ModStore.fetchModReleases(modName);
} catch (error: any) {
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
logger.error(`Error fetching mod from portal: ${error.message}`);
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
} else {
releases = null;
}
}
}
_modPortalReleases.set(modName, releases);
return releases;
}
function selectModRelease(releases: ModPortalModType | null, versionRange: lib.ModVersionRange) {
if (!releases || !releases.releases) {
return null;
}
// Select the latest matching version
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
return null;
}
// Create the mod info class from the selected
try {
return lib.ModInfo.fromJSON({
...release.info_json, // includes dependencies
sha1: release.sha1,
version: release.version,
name: releases.name,
author: releases.owner,
title: releases.title,
});
} catch {
// Can error if there are invalid dependencies
errors.set(releases.name, "invalidDependency");
return null;
}
}
while (searchQueue.length > 0) {
// Dequeue the next mod, skip if incompatible
const mod = searchQueue.shift()!;
if (mod.incompatible) {
errors.set(mod.name, "incompatible");
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);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
}
// Update the version range if needed
if (mod.version) {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
}
}
// Do not add dependencies for errors, skips, optionals, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (errors.has(mod.name) || skipSearch.has(mod.name) || optionalRequirements.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
}
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const releases = await fetchModReleases(mod.name);
const release = selectModRelease(releases, versionRange);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || release.integerVersion > candidate.integerVersion)) {
candidate = release;
}
}
// Queue the dependencies of the new candidate
if (candidate) {
candidateReleases.set(mod.name, candidate);
for (const dep of candidate.dependencies) {
searchQueue.push(dep);
}
} else {
candidateReleases.delete(mod.name);
skipSearch.add(mod.name);
}
}
// Add the not found dependencies as errors
for (const modName of dependencyRequirements.keys()) {
if (!candidateReleases.has(modName) && !errors.has(modName)) {
errors.set(modName, "notFound");
}
}
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()], errors,
);
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
await this._controller.modStore.deleteMod(request.name, request.version);
}
@@ -577,22 +874,21 @@ export default class ControlConnection extends BaseConnection {
}
async handleRoleListRequest() {
return [...this._controller.userManager.roles.values()];
return [...this._controller.roles.values()];
}
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]);
let lastId = Math.max.apply(null, [...this._controller.roles.keys()]);
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
this._controller.userManager.dirty = true;
this._controller.roles.set(lib.Role.fromJSON({ id, ...request }));
return id;
}
async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) {
let { id, name, description, permissions } = request;
let role = this._controller.userManager.roles.get(id);
let role = this._controller.roles.getMutable(id);
if (!role) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
@@ -600,112 +896,109 @@ export default class ControlConnection extends BaseConnection {
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._controller.rolePermissionsUpdated(role);
this._controller.roles.set(role);
}
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
let role = this._controller.userManager.roles.get(request.id);
let role = this._controller.roles.get(request.id);
if (!role) {
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
}
role.grantDefaultPermissions();
this._controller.rolePermissionsUpdated(role);
this._controller.roles.set(role);
}
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
let id = request.id;
if (!this._controller.userManager.roles.delete(id)) {
const role = this._controller.roles.get(id);
if (!role) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
this._controller.userManager.dirty = true;
this._controller.roles.delete(role);
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
for (const user of this._controller.users.values()) {
if (user.removeRole(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
let name = request.name;
let user = this._controller.userManager.users.get(name);
let user = this._controller.users.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user;
return user.toUserDetails();
}
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
async handleUserListRequest(): Promise<lib.UserDetails[]> {
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
this._controller.users.createUser(request.name);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.userManager.users.get(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
if (user.name !== this.user.name) {
if (user.id !== this.user.id) {
this.user.checkPermission("core.user.revoke_other_token");
}
user.invalidateToken();
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.name === user.name) {
if (controlConnection.user.id === user.id) {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.userManager.users.get(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
for (let roleId of request.roles) {
if (!this._controller.userManager.roles.has(roleId)) {
if (!this._controller.roles.has(roleId)) {
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
}
}
user.roleIds = new Set(request.roles);
user.set("roleIds", new Set(request.roles));
this._controller.userPermissionsUpdated(user);
this._controller.usersUpdated([user]);
}
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
let { name, create, admin } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this._controller.usersUpdated([user]);
user.set("isAdmin", admin);
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
}
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.users.getByNameMutable(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
@@ -713,37 +1006,34 @@ export default class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
this._controller.usersUpdated([user]);
user.saveRecord();
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
}
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
user.set("isWhitelisted", whitelisted);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.userManager.users.get(name);
let user = this._controller.users.getByName(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
user.isDeleted = true;
this._controller.userManager.users.delete(name);
this._controller.usersUpdated([user]);
this._controller.users.deleteUser(user);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -756,6 +1046,238 @@ export default class ControlConnection extends BaseConnection {
}
}
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
if (request.importType === "users") {
// Update all fields for all users
const users = request.users as Extract<typeof request.users, { username: string }[]>;
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
for (const user of this._controller.users.valuesMutable()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
}
if (user.isBanned && !banned.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
if (user.isWhitelisted && !whitelist.has(user.name)) {
user.isWhitelisted = false;
updated.set(user.id, user);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.users.valuesMutable()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
}
} else {
// Whitelist and admin have the same logic
const users = request.users as string[];
const expected = new Set(users.map(u => u.toLowerCase()));
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.valuesMutable()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
}
}
}
}
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<User["id"], User>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
await this.handleUserBulkRestoreRequest(request, updated);
}
// Will get a user or attempt to create one
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
const getUserOrCreate = (username: string) => {
const user = this._controller.users.getByNameMutable(username);
if (user) { return updated.get(user.id) ?? user; }
this.user.checkPermission("core.user.create");
return this._controller.users.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
if (request.importType === "users") {
// Update all fields
const users = request.users as Extract<typeof request.users, { username: string }[]>;
for (const user of users) {
const cUser = getUserOrCreate(user.username);
if (user.is_admin && !cUser.isAdmin) {
cUser.isAdmin = true;
updated.set(cUser.id, cUser);
}
if (user.is_banned && !cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.ban_reason ?? "";
updated.set(cUser.id, cUser);
}
if (user.is_whitelisted && !cUser.isWhitelisted) {
cUser.isWhitelisted = true;
updated.set(cUser.id, cUser);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
for (const user of users) {
if (typeof user === "string") {
const cUser = getUserOrCreate(user);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = "";
updated.set(cUser.id, cUser);
}
} else {
const cUser = getUserOrCreate(user.username);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.reason;
updated.set(cUser.id, cUser);
}
}
}
} else {
// Whitelist and admin have the same logic
const users = request.users as string[];
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of users) {
const cUser = getUserOrCreate(user);
if (!cUser[prop]) {
cUser[prop] = true;
updated.set(cUser.id, cUser);
}
}
}
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.users.records.setMany(updatedUsers);
// Resync the host user lists
const adminlist: Set<string> = new Set();
const banlist: Map<string, string> = new Map();
const whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.id);
}
}
this._controller.sendEvent(
new lib.SyncUserListsEvent(adminlist, banlist, whitelist),
lib.Address.fromShorthand("allHosts")
);
return backup ?? [];
}
async handleUserBulkExportRequest(request: lib.UserBulkExportRequest) {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
send = true;
userToSend.is_banned = true;
if (user.banReason !== "") {
userToSend.ban_reason = user.banReason;
}
}
if (user.isAdmin) {
send = true;
userToSend.is_admin = true;
}
if (user.isWhitelisted) {
send = true;
userToSend.is_whitelisted = true;
}
if (send) {
usersToSend.push(userToSend);
}
}
return new lib.ClusterioUserExport(usersToSend);
} else if (request.exportType === "bans") {
// Bans have extra logic to handle ban reason
const usersToSend = [] as Array<string | { username: string, reason: string }>;
for (const user of this._controller.users.values()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
} else {
usersToSend.push(user.name);
}
}
}
return usersToSend;
}
// This is a whitelist or admins export
const usersToSend = [] as string[];
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
}
return usersToSend;
}
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");
}
return await lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
if (!this._controller.config.get("controller.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos);
}
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
if (!this._controller.config.get("controller.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
return await lib.handlePluginInstall(request.pluginPackage);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
this.ws_dumper = data => {
if (this.connector.connected) {
File diff suppressed because it is too large. Load diff
-93
View File
@@ -1,93 +0,0 @@
import { Static, Type } from "@sinclair/typebox";
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
import type UserManager from "./UserManager";
export default class ControllerUser extends User implements IControllerUser {
constructor(
public userManager: UserManager,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
userManager,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this.userManager.roles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this.userManager.roles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+129 -63
View File
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
/**
@@ -16,7 +16,7 @@ import HostInfo from "./HostInfo";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostInfo;
private info: HostRecord;
plugins: Map<string, string>;
constructor(
@@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostInfo(
this.info = new HostRecord(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
@@ -42,13 +42,12 @@ export default class HostConnection extends BaseConnection {
0,
false,
);
this._controller.hosts.set(this.id, this.info);
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
@@ -57,10 +56,9 @@ export default class HostConnection extends BaseConnection {
}
this.connector.on("close", () => {
const now = Date.now();
// Update status to unknown for instances on this host.
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.values()) {
const instances: InstanceRecord[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
}
@@ -68,21 +66,24 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
instance.updatedAtMs = now;
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instanceDetailsUpdated(instances);
this._controller.instances.records.setMany(instances);
this.info.connected = false;
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
});
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
}
validateIngress(message: lib.MessageRoutable) {
@@ -155,14 +156,33 @@ export default class HostConnection extends BaseConnection {
return this.info.id;
}
/**
* Remote address of the host this connection is connected to
*
* @returns {string} host remote address.
*/
get remoteAddress() {
return this.info.remoteAddress;
}
async handleGetFactorioCredentialsRequest() {
if (this._controller.config.get("controller.share_factorio_credentials_with_hosts")) {
return {
username: this._controller.config.get("controller.factorio_username") ?? undefined,
token: this._controller.config.get("controller.factorio_token") ?? undefined,
};
}
return {};
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
let instance = this._controller.instances.get(request.instanceId);
let instance = this._controller.instances.getMutable(request.instanceId);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the host it originated from if it was
@@ -187,10 +207,15 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
// If the instance has transitioned to running then updated the started at time
if (request.status === "running" && prev === "starting") {
instance.startedAtMs = Date.now();
}
instance.gamePort = request.gamePort;
instance.updatedAtMs = Date.now();
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._controller.instanceDetailsUpdated([instance]);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -205,12 +230,12 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceInfo[] = [];
const instanceUpdates: InstanceRecord[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
let controllerInstance = this._controller.instances.getMutable(instanceConfig.get("instance.id"));
if (controllerInstance) {
// Check if this instance is assigned somewhere else.
if (controllerInstance.config.get("instance.assigned_host") !== this.id) {
@@ -225,9 +250,12 @@ export default class HostConnection extends BaseConnection {
let prev = controllerInstance.status;
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.updatedAtMs = Date.now();
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
controllerInstance.factorioVersion = instanceData.factorioVersion;
if (instanceData.status === "running" && prev === "starting") {
controllerInstance.startedAtMs = Date.now();
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
@@ -236,42 +264,38 @@ export default class HostConnection extends BaseConnection {
}
instanceConfig.set("instance.assigned_host", this.id);
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
Date.now(),
);
this._controller.instances.set(instanceConfig.get("instance.id"), newInstance);
this._controller.instancesDirty = true;
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
instanceUpdates.push(newInstance);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
if (instanceUpdates.length) {
this._controller.instanceDetailsUpdated(instanceUpdates);
}
// 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.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.userManager.users.values()) {
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.name);
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.name, user.banReason);
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.name);
whitelist.add(user.id);
}
}
@@ -279,33 +303,27 @@ export default class HostConnection extends BaseConnection {
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
const now = Date.now();
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
const existingSave = this._controller.saves.get(save.id);
if (existingSave && save.equals(existingSave)) {
continue;
}
save.updatedAtMs = now;
this._controller.saves.set(save.id, save);
updates.push(save);
}
if (event.instanceId !== undefined) {
const updatedSaves = new Set(event.updates.map(s => s.id));
for (const [id, save] of this._controller.saves) {
if (save.instanceId === event.instanceId && !updatedSaves.has(id)) {
save.isDeleted = true;
save.updatedAtMs = now;
updates.push(save);
this._controller.saves.delete(id);
for (const save of this._controller.saves.values()) {
if (save.instanceId === event.instanceId && !updatedSaves.has(save.id)) {
deletes.push(save);
}
}
}
// Hosts eagerly send updates, which means we may get an update
// where nothing actualy changed.
if (updates.length) {
this._controller.savesUpdated(updates);
}
// where nothing actually changed.
this._controller.saves.setMany(updates);
this._controller.saves.deleteMany(deletes);
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
@@ -318,21 +336,18 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.users.get(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
if (event.type === "join") {
this._controller.userManager.notifyJoin(user, instanceId);
user.notifyJoin(instanceId);
} else if (event.type === "leave") {
this._controller.userManager.notifyLeave(user, instanceId);
user.notifyLeave(instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
this._controller.usersUpdated([user]);
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
@@ -342,4 +357,55 @@ export default class HostConnection extends BaseConnection {
stats: event.stats,
});
}
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
async snoopInstanceBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.isBanned = event.banned;
user.banReason = event.reason;
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
resaon: event.reason,
});
}
async snoopInstanceWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
}
@@ -1,10 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
/** Underlying data class for hosts on the controller */
export default class HostRecord {
constructor(
/** Id of this host */
public id: number,
@@ -33,12 +31,12 @@ export default class HostInfo {
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"connected": Type.Boolean(),
"remote_address": Type.String(),
"public_address": Type.String(),
"token_valid_after": Type.Number(),
"updated_at_ms": Type.Number(),
"is_deleted": Type.Boolean(),
"connected": Type.Optional(Type.Boolean()),
"remote_address": Type.Optional(Type.String()),
"public_address": Type.Optional(Type.String()),
"token_valid_after": Type.Optional(Type.Number()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -56,19 +54,50 @@ export default class HostInfo {
);
}
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
toJSON() {
const json = {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
connected: this.connected,
remote_address: this.remoteAddress,
public_address: this.publicAddress,
token_valid_after: this.tokenValidAfter,
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
} as Static<typeof HostRecord.jsonSchema>;
if (this.connected !== false) {
json.connected = this.connected;
}
if (this.remoteAddress !== "") {
json.remote_address = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.public_address = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.token_valid_after = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.is_deleted = this.isDeleted;
}
return json;
}
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
return new HostRecord(
details.id,
details.name,
details.version,
plugins,
details.connected,
details.remoteAddress,
details.publicAddress,
details.tokenValidAfter,
details.updatedAtMs,
details.isDeleted,
);
}
toHostDetails() {
@@ -85,4 +114,3 @@ export default class HostInfo {
);
}
}
-55
View File
@@ -1,55 +0,0 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.updatedAtMs,
);
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.updatedAtMs,
);
}
get isDeleted() {
return this.status === "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
}
}
+274
View File
@@ -0,0 +1,274 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
tags: ["clusterio"],
max_players: 0,
visibility: { public: true, lan: true },
game_password: "",
require_user_verification: true,
max_upload_in_kilobytes_per_second: 0,
max_upload_slots: 5,
ignore_player_limit_for_returning_players: false,
allow_commands: "admins-only",
autosave_interval: 10,
autosave_slots: 5,
afk_autokick_interval: 0,
auto_pause: false,
only_admins_can_pause_the_game: true,
autosave_only_on_server: true,
non_blocking_saving: false,
} as const;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
) {
for (const instance of records.values()) {
this.addInstanceHooks(instance);
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: InstanceRecord["id"]): boolean {
return this.records.has(id);
}
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
return this.records.get(id);
}
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
return this.getMutable(id);
}
valuesMutable(): IterableIterator<InstanceRecord> {
return this.records.values();
}
values(): IterableIterator<Readonly<InstanceRecord>> {
return this.valuesMutable();
}
/**
* Get instance by ID for a request.
*
* Convenience wrapper around {@link getMutable} that throws
* if the instance does not exist.
*
* @param instanceId - ID of instance to get.
* @returns The instance record.
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getForRequest(instanceId: number): InstanceRecord {
const instance = this.records.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/* ------------------------------------------------------------------------- */
/* Internal Helpers */
/* ------------------------------------------------------------------------- */
private async _notifyStatusChanged(
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
}
private async _notifyConfigFieldChanged(
instance: InstanceRecord,
field: string,
curr: unknown,
prev: unknown,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
}
/**
* Attach change listeners to an instance record.
*
* Hooks into configuration field changes and ensures the datastore
* is updated and plugins are notified when a config field changes.
*
* @param instance - The instance record to attach hooks to.
* @internal
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
});
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Create a new instance.
*
* @example
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
* const instance = await controller.instances.createInstance(config);
*
* @param instanceConfig - Config to base the newly created instance on.
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
* @returns The created instance record.
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
*/
async createInstance(
instanceConfig: lib.InstanceConfig,
_suppressChanges?: boolean,
): Promise<InstanceRecord> {
const instanceId = instanceConfig.get("instance.id");
if (this.records.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
if (!_suppressChanges) {
this.records.set(instance);
await this._notifyStatusChanged(instance);
}
return instance;
}
/**
* Change the assigned host of an instance.
*
* Unassigns the instance from the currently assigned host (if connected)
* and assigns it to the provided host.
*
* This is the only supported way of modifying the
* `instance.assigned_host` configuration entry.
*
* Note:
* - This does NOT transfer any files or saves between hosts.
* - If the instance is already assigned to the specified host, no action is taken.
*
* @param instanceId - ID of instance to assign.
* @param hostId - ID of host to assign the instance to. If undefined,
* the instance will be unassigned.
*
* @throws {module:lib.RequestError} if the instance does not exist.
* @throws {module:lib.RequestError} if the target host is not connected.
*/
async assignInstance(
instanceId: number,
hostId: number | null,
): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostConnections = this._controller.wsServer.hostConnections;
const currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost === hostId) {
return;
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
newHostConnection = hostConnections.get(hostId);
if (!newHostConnection) {
throw new lib.RequestError("Target host is not connected to the controller");
}
}
if (currentAssignedHost !== null) {
const oldHostConnection = hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(
new lib.InstanceUnassignInternalRequest(instanceId),
);
}
}
this._controller.clearSavesOfInstance(instanceId);
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} else {
instance.status = "unassigned";
}
this.records.set(instance);
}
/**
* Unassign an instance from its current host.
*
* Convenience wrapper around {@link assignInstance} that removes
* the assigned host.
*
* @param instanceId - ID of instance to unassign.
*/
async unassignInstance(instanceId: number): Promise<void> {
return this.assignInstance(instanceId, null);
}
/**
* Delete an instance permanently.
*
* Sends a delete request to the assigned host (if any),
* removes the instance from the datastore,
* clears associated saves and user statistics.
*
* @param instanceId - ID of instance to delete.
*
* @throws {module:lib.RequestError} if the instance does not exist.
*/
async deleteInstance(instanceId: number): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this._controller.sendTo(
{ hostId },
new lib.InstanceDeleteInternalRequest(instanceId),
);
}
const prevStatus = instance.status;
this.records.delete(instance);
this._controller.clearSavesOfInstance(instanceId);
this._controller.users.clearStatsOfInstance(instanceId);
await this._notifyStatusChanged(instance, prevStatus);
}
}
+99
View File
@@ -0,0 +1,99 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: lib.TargetVersion,
public startedAtMs = 0,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
get id(): number {
return this.config.get("instance.id");
}
get isDeleted() {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
);
}
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
const json: any = {
config: this.config.toJSON(),
status: this.status,
};
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
return json;
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord";
import { Static } from "@sinclair/typebox";
/**
* Represents a user as viewed from the controller.
*
* All methods are safe to use when readonly.
* Only use mutable when needing to change multiple values.
*/
export default class User extends UserRecord implements IUser {
constructor(
private _controllerUsers: SubscribableDatastore<UserRecord>,
private _controllerRoles: SubscribableDatastore<Role>,
...args: ConstructorParameters<typeof UserRecord>
) {
super(...args);
}
static fromJSON(
json: Static<typeof this.jsonSchema>,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
) {
return this.fromUserRecord(
UserRecord.fromJSON(json),
_controllerUsers,
_controllerRoles,
);
}
static fromUserRecord(
userRecord: UserRecord,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
): User {
return new this(
_controllerUsers,
_controllerRoles,
userRecord.tokenValidAfter,
userRecord.name,
userRecord.roleIds,
userRecord.instances,
userRecord.isAdmin,
userRecord.isBanned,
userRecord.isWhitelisted,
userRecord.banReason,
userRecord.updatedAtMs,
userRecord.isDeleted,
userRecord.instanceStats,
);
}
/** Roles this user has */
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/** Save the underlying user record */
saveRecord() {
this._controllerUsers.set(this);
}
/** Set a property on the underlying record, then save it. */
set<K extends keyof this>(key: K, value: this[K]) {
this[key] = value;
this.saveRecord();
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
this.saveRecord();
}
/** Add a role to this user */
addRole(roleId: number) {
const role = this._controllerRoles.get(roleId);
if (!role) {
throw new Error(`role ${roleId} does not exist`);
}
this.roleIds.add(roleId);
this.saveRecord();
}
/** Remove a role from user */
removeRole(roleId: number) {
if (!this.roleIds.delete(roleId)) {
return false;
}
this.saveRecord();
return true;
}
/** Set a user as being online on a specific instance */
notifyJoin(instanceId: number) {
this.instances.add(instanceId);
this.saveRecord();
}
/** Set a user as being offline on a specific instance */
notifyLeave(instanceId: number) {
this.instances.delete(instanceId);
this.saveRecord();
}
/** Clear the stats of a player on a specific instance */
clearInstanceStats(instanceId: number) {
this.instances.delete(instanceId);
this.instanceStats.delete(instanceId);
this.recalculatePlayerStats();
this.saveRecord();
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (const roleId of this.roleIds) {
const role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+111 -71
View File
@@ -1,125 +1,165 @@
import fs from "fs-extra";
import fs from "node:fs/promises";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
/**
* Manages users and roles
* Access layer for users on the controller.
*
* In performance critical paths the raw records can be accessed directly.
* Otherwise this class will wrap data in a view class with a set and save method.
*
* @alias module:controller/src/UserManager
*/
export default class UserManager {
roles: Map<number, lib.Role> = new Map();
users: Map<string, ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
private _config: lib.ControllerConfig
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
) {
}
async load(filePath: string): Promise<void> {
/**
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
* This migration separates them so they can be loaded by datastore.
*
* @param usersFilePath The src and dst file path for the users json file
* @param rolesFilePath The dst for the roles json file
*/
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
for (let serializedRole of content.roles) {
let role = lib.Role.fromJSON(serializedRole);
this.roles.set(role.id, role);
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
}
for (let serializedUser of content.users) {
let user = ControllerUser.fromJSON(serializedUser, this);
this.users.set(user.name, user);
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
// Create default roles if loading failed
lib.ensureDefaultAdminRole(this.roles);
lib.ensureDefaultPlayerRole(this.roles);
}
}
async save(filePath:string): Promise<void> {
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.toJSON());
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.toJSON(true));
}
let serialized = {
users: serializedUsers,
roles: serializedRoles,
};
this.dirty = false;
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
has(id: UserRecord["id"]) {
return this.records.has(id);
}
getMutable(id: UserRecord["id"]) {
const userRecord = this.records.getMutable(id);
if (!userRecord) {
return undefined;
}
return User.fromUserRecord(
userRecord,
this.records,
this._controllerRoles,
);
}
get(id: UserRecord["id"]): Readonly<User> | undefined {
return this.getMutable(id);
}
hasName(name: UserRecord["name"]) {
return this.has(name.toLowerCase());
}
getByNameMutable(name: UserRecord["name"]) {
return this.getMutable(name.toLowerCase());
}
getByName(name: UserRecord["name"]) {
return this.get(name.toLowerCase());
}
* valuesMutable(): IterableIterator<User> {
const roles = this._controllerRoles;
const users = this.records;
for (const record of users.values()) {
yield User.fromUserRecord(record, users, roles);
}
}
values(): IterableIterator<Readonly<User>> {
return this.valuesMutable();
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Creates a new user and add it to the user database.
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name:string): ControllerUser {
if (this.users.has(name)) {
createUser(name: string): User {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new ControllerUser(this, 0, name, roles);
this.users.set(name, user);
this.dirty = true;
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
return user;
}
/** Get or create a user with a given name. */
getOrCreateUser(name: string): User {
return this.getByNameMutable(name) ?? this.createUser(name);
}
/**
* Sign access token for the given user name
* Deletes an existing user
*
* @param name - user name to sign token for
* @param user - user to delete
*/
deleteUser(user: UserRecord) {
return this.records.delete(user);
}
/* ------------------------------------------------------------------------- */
/* Helpers */
/* ------------------------------------------------------------------------- */
/**
* Sign access token for the given user
*
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(name: string): string {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: name },
Buffer.from(this._config.get("controller.auth_secret"), "base64")
{ aud: "user", user: user.id },
// auth secret is never null after startup
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
);
}
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
}
this.dirty = true;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { UserDetails } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/** Underlying data class for users on the controller */
export default class UserRecord extends UserDetails {
constructor(
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof UserDetails>
) {
super(...args);
}
static jsonSchema = Type.Object({
token_valid_after: Type.Optional(Type.Number()),
...UserDetails.jsonSchema.properties,
});
// _a and _b are a ts workaround for allowing User to have extra arguments
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
return this.fromUserDetails(
UserDetails.fromJSON(json),
json.token_valid_after,
);
}
toJSON() {
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
if (this.tokenValidAfter > 0) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
static fromUserDetails(
userDetails: UserDetails,
tokenValidAfter = 0,
) {
return new this(
tokenValidAfter,
userDetails.name,
userDetails.roleIds,
userDetails.instances,
userDetails.isAdmin,
userDetails.isBanned,
userDetails.isWhitelisted,
userDetails.banReason,
userDetails.updatedAtMs,
userDetails.isDeleted,
userDetails.instanceStats,
);
}
toUserDetails() {
return new UserDetails(
this.name,
this.roleIds,
this.instances,
this.isAdmin,
this.isBanned,
this.isWhitelisted,
this.banReason,
this.updatedAtMs,
this.isDeleted,
this.instanceStats,
);
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: UserDetails) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
// Player stats must be recalculated after modifying instance stats
this.recalculatePlayerStats();
}
}
+38 -36
View File
@@ -78,11 +78,11 @@ export default class WsServer {
let disconnectTasks = [];
for (let controlConnection of this.controlConnections.values()) {
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
disconnectTasks.push(controlConnection.disconnect(lib.ConnectionClosed.GoingAway, "Server Quit"));
}
for (let hostConnection of this.hostConnections.values()) {
disconnectTasks.push(hostConnection.disconnect(1001, "Server Quit"));
disconnectTasks.push(hostConnection.disconnect(lib.ConnectionClosed.GoingAway, "Server Quit"));
}
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
@@ -97,7 +97,7 @@ export default class WsServer {
}
for (let socket of this.pendingSockets) {
socket.close(1001, "Server Quit");
socket.close(lib.ConnectionClosed.GoingAway, "Server Quit");
}
}
@@ -170,7 +170,7 @@ export default class WsServer {
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
socket.close(lib.ConnectionClosed.InternalError, "Unexpected error");
});
});
}
@@ -184,7 +184,7 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid message`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, `Invalid message: ${err.message}`);
socket.close(lib.ConnectionClosed.ProtocolError, `Invalid message: ${err.message}`);
return;
}
@@ -196,14 +196,14 @@ ${err.stack}`
if (message.type !== "registerHost" && message.type !== "registerControl") {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Bad handshake");
socket.close(lib.ConnectionClosed.ProtocolError, "Bad handshake");
return;
}
if (this.stopAcceptingNewSessions) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)}, server is shutting down`);
wsRejectedConnectionsCounter.inc();
socket.close(1001, "Shutting down");
socket.close(lib.ConnectionClosed.GoingAway, "Shutting down");
return;
}
@@ -223,10 +223,7 @@ ${err.stack}`
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
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);
@@ -239,14 +236,16 @@ ${err.stack}`
}
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
if (this.controller.recoveryMode) {
logger.warn(`Recovery | rejected host connection attempt for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(lib.ConnectionClosed.RecoveryMode, "Connection Refused: Controller in recovery mode");
return;
}
try {
// migrate: allow pre-rename tokens issued to hosts before alpha-14
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -263,15 +262,26 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
socket.close(lib.ConnectionClosed.Unauthorized, `Authentication failed: ${err.message}`);
return;
}
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
let connection = this.hostConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`);
await connection.disconnect(1008, "Registered from another connection");
if (connection.remoteAddress !== this.remoteAddr(req)) {
logger.verbose(
`WsServer | disallowed duplicate connection from ${this.remoteAddr(req)} for host ${data.id}`
);
wsRejectedConnectionsCounter.inc();
socket.close(lib.ConnectionClosed.PolicyViolation, "Host already connected from another address");
return;
}
logger.verbose(
`WsServer | disconnecting duplicate connection from host ${data.id}`
);
await connection.disconnect(
lib.ConnectionClosed.PolicyViolation, "Newer connection for host from same address"
);
}
logger.info(
@@ -284,6 +294,8 @@ ${err.stack}`
);
}
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
// Handle close before HostConnection does. This avoids a host
// connection event happening between the connection breaking and
// it being removed from the host connections list.
@@ -304,17 +316,12 @@ ${err.stack}`
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.userManager.users.get(tokenPayload.user);
user = this.controller.users.getByNameMutable(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -326,7 +333,7 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
socket.close(lib.ConnectionClosed.Unauthorized, `Authentication failed: ${err.message}`);
return;
}
@@ -356,12 +363,7 @@ ${err.stack}`
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+5 -8
View File
@@ -1,6 +1,7 @@
import type WebSocket from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
const { logger } = lib;
@@ -113,8 +114,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
_timedOut() {
logger.verbose("Connector | Connection timed out");
this._reset();
this.emit("close");
this._close();
}
_attachSocketHandlers() {
@@ -130,8 +130,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._socket = null;
if (this._closing) {
this._reset();
this.emit("close");
this._close();
} else {
this._state = "resuming";
@@ -187,8 +186,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
}
if (this._state === "resuming") {
this._reset();
this.emit("close");
this._close();
return;
}
@@ -223,8 +221,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
*/
setClosing() {
if (this._state === "resuming") {
this._reset();
this.emit("close");
this._close();
return;
}
+36 -48
View File
@@ -1,10 +1,10 @@
import type { Application, Request, Response } from "express";
import type Controller from "./Controller";
import Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
import events from "events";
import fs from "fs-extra";
import fs from "node:fs/promises";
import JSZip from "jszip";
import jwt from "jsonwebtoken";
import path from "path";
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
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` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
let 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.
@@ -136,6 +134,12 @@ function getPlugins(req: Request, res: Response) {
res.send(plugins);
}
function getClusterName(req: Request, res: Response) {
const controller: Controller = req.app.locals.controller;
const clusterName = controller.config.get("controller.name");
res.json({ name: clusterName });
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
@@ -144,12 +148,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
try {
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -176,16 +175,11 @@ function validateUserToken(req: Request, res: Response, next: any) {
}
try {
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.userManager.users.get(tokenPayload.user);
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -218,7 +212,7 @@ async function uploadExport(req: Request, res: Response) {
res.sendStatus(400);
return;
}
let modPack = res.app.locals.controller.modPacks.get(modPackId);
let modPack = res.app.locals.controller.modPacks.getMutable(modPackId);
if (!modPack) {
res.sendStatus(400);
return;
@@ -236,13 +230,14 @@ async function uploadExport(req: Request, res: Response) {
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
"export/defines.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
let settingPrototypes = {};
let settingPrototypes: lib.ExportSettings = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
@@ -259,13 +254,19 @@ async function uploadExport(req: Request, res: Response) {
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacksUpdated([modPack]);
res.app.locals.controller.modPacks.set(modPack);
res.sendStatus(200);
}
type ProxyStreamEvents = {
"error": [ err: any ],
"close": [],
"timeout": [],
"source": [],
}
export interface ProxyStream {
id: string;
@@ -274,7 +275,7 @@ export interface ProxyStream {
size?: string;
mime?: string;
filename: string | null;
events: events.EventEmitter;
events: events.EventEmitter<ProxyStreamEvents>;
timeout: ReturnType<typeof setTimeout>;
}
@@ -542,30 +543,16 @@ async function uploadMod(req: Request, res: Response) {
}
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
const filePath = path.join(modsDirectory, filename);
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
let writeStream;
try {
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
checkModName(modInfo.filename);
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
req.app.locals.controller.modStore.addMod(modInfo);
mods.push(modInfo.toJSON());
@@ -575,15 +562,13 @@ async function uploadMod(req: Request, res: Response) {
stream.resume();
// Attempt to clean up.
if (writeStream) {
writeStream.destroy();
}
writeStream.destroy();
try {
await fs.unlink(path.join(modsDirectory, tempFilename));
await fs.unlink(tempFilePath);
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
}
}
}
@@ -658,6 +643,7 @@ 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("/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)
@@ -684,10 +670,12 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/error",
];
@@ -7,6 +7,5 @@
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"rootDir": "..",
"outDir": "dist/browser",
}
}
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
devServer: {
+130 -49
View File
@@ -2,11 +2,10 @@
"use strict";
const child_process = require("child_process");
const fs = require("fs-extra");
const fs = require("node:fs/promises");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
@@ -16,6 +15,7 @@ const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// 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
@@ -97,24 +97,47 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function pathExists(filePath) {
try {
await fs.access(filePath);
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
}
return true;
}
async function safeOutputFile(file, data, options={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
await fs.outputFile(temporary, data, options);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(temporary, data, { ...options, flush: true });
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, { shell: true }, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
let child = child_process.execFile(
cmd,
escaped,
{
shell: true,
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
},
(err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
}
});
);
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { logger.verbose(line.toString()); });
child.stdout.pipe(stdout);
@@ -211,7 +234,7 @@ async function migrateRename(args) {
}
async function migrateConfig(source, destination) {
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
if (await pathExists(source) && !await pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
));
@@ -223,7 +246,7 @@ async function migrateRename(args) {
await migrateConfig("config-slave.json", "config-host.json");
let instancesFile = path.join("database", "instances.json");
if (await fs.pathExists(instancesFile)) {
if (await pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
@@ -231,7 +254,7 @@ async function migrateRename(args) {
}
let usersFile = path.join("database", "users.json");
if (await fs.pathExists(usersFile)) {
if (await pathExists(usersFile)) {
let users = JSON.parse(await fs.readFile(usersFile));
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
@@ -248,7 +271,7 @@ async function migrateRename(args) {
async function migrateLog(inputFile, outputFile) {
let lineStream = new LineSplitter({ readableObjectMode: true });
let fileStream = fs.createReadStream(inputFile);
let fileStream = (await fs.open(inputFile)).createReadStream();
fileStream.pipe(lineStream);
let lines = [];
@@ -268,7 +291,7 @@ async function migrateRename(args) {
}
async function migrateLogsDir(inputLogs, outputLogs) {
if (await fs.pathExists(inputLogs)) {
if (await pathExists(inputLogs)) {
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
for (let logFile of await fs.readdir(inputLogs)) {
if (!logFile.endsWith(".log")) {
@@ -276,7 +299,7 @@ async function migrateRename(args) {
}
let inputFile = path.join(inputLogs, logFile);
let outputFile = path.join(outputLogs, rename(logFile));
if (!await fs.pathExists(outputFile)) {
if (!await pathExists(outputFile)) {
await migrateLog(inputFile, outputFile);
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
}
@@ -287,16 +310,16 @@ async function migrateRename(args) {
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
if (
await fs.pathExists(path.join("logs", "cluster"))
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
await pathExists(path.join("logs", "cluster"))
&& !await pathExists(path.join("logs", "cluster-prerename"))
) {
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
}
if (
await fs.pathExists("sharedMods")
&& !await fs.pathExists("mods")
await pathExists("sharedMods")
&& !await pathExists("mods")
) {
logger.info("Moving sharedMods/ to mods/");
await fs.rename("sharedMods", "mods");
@@ -318,14 +341,14 @@ async function migrateRename(args) {
await execFile(`npm${scriptExt}`, ["update"]);
}
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
if (hasRunMaster || hasRunSlave) {
logger.info("Writing run scripts");
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode);
await writeScripts(mode, "");
}
logger.info(
@@ -344,7 +367,7 @@ async function migrateRename(args) {
async function installClusterio(mode, plugins) {
try {
await fs.outputFile("package.json", JSON.stringify({
await fs.writeFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -386,7 +409,7 @@ async function installClusterio(mode, plugins) {
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
// eslint-disable-next-line node/global-require
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
pluginList.set(pluginInfo.name, plugin);
}
@@ -410,7 +433,7 @@ async function groupIdToName(gid) {
}
}
async function writeScripts(mode) {
async function writeScripts(mode, selfSignedCert) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
@@ -464,12 +487,13 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
:restart
${extraCaCertsLine}:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
@@ -477,18 +501,20 @@ goto restart
`
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
while true; do
${extraCaCertsLine}while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
"systemd/clusteriohost.service",
`\
@@ -502,7 +528,7 @@ WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -551,6 +577,25 @@ async function inquirerMissingArgs(args) {
},
},
], answers);
if (args.httpPort) { answers.httpPort = args.httpPort; }
answers = await inquirer.prompt([
{
type: "input",
name: "httpPort",
message: "HTTP port to listen on",
default: 8080,
validate: input => {
if (!input) {
return "May not be empty";
}
if (!Number.isInteger(Number(input))) {
return "Must be a number";
}
return true;
},
},
], answers);
}
if (answers.mode === "host") {
@@ -594,8 +639,8 @@ async function inquirerMissingArgs(args) {
answers.publicAddress = args.publicAddress;
} else {
try {
let result = await phin("https://api.ipify.org/");
myIp = result.body.toString();
let result = await fetch("https://api.ipify.org/");
myIp = await result.text();
} catch (err) { /* ignore */ }
}
answers = await inquirer.prompt([
@@ -611,7 +656,7 @@ async function inquirerMissingArgs(args) {
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
@@ -655,10 +700,22 @@ async function inquirerMissingArgs(args) {
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.ensureDir("factorio");
await fs.mkdir("factorio", { recursive: true });
}
}
if (["standalone", "controller", "host"].includes(answers.mode)) {
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "remoteNpm",
message: "Allow remote updates via npm?",
default: true,
},
], answers);
}
if (answers.mode === "plugin-template") {
if (args["plugin-template"] && args["plugin-template"].length > 0) {
answers.pluginTemplate = args["plugin-template"];
@@ -680,6 +737,7 @@ async function inquirerMissingArgs(args) {
{ name: "Command Line", value: "ctl" },
{ name: "Web UI", value: "web" },
{ name: "No Typescript", value: "js" },
{ name: "No Config", value: "no_config" },
],
},
{
@@ -710,12 +768,16 @@ async function inquirerMissingArgs(args) {
}
async function downloadLinuxServer() {
let res = await phin("https://factorio.com/get-download/stable/headless/linux64");
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// 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 version = filename.match(/(?<=factorio_headless_x64_).*(?=\.tar\.xz)/)[0];
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;
@@ -723,26 +785,21 @@ async function downloadLinuxServer() {
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await fs.pathExists(factorioDir)) {
if (await pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await phin({
url: url.href,
stream: true,
});
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 = fs.createWriteStream(tmpArchivePath);
res.pipe(writeStream);
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.ensureDir(tmpFactorioDir);
await fs.mkdir(tmpFactorioDir, { recursive: true });
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
@@ -751,8 +808,10 @@ async function downloadLinuxServer() {
throw e;
}
await fs.unlink(archivePath);
await fs.mkdir("factorio", { recursive: true });
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
@@ -775,6 +834,13 @@ async function main() {
.option("admin", {
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
})
.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",
})
@@ -790,6 +856,12 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("self-signed-cert", {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
})
@@ -854,6 +926,9 @@ async function main() {
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
@@ -868,9 +943,13 @@ async function main() {
result = await execController(["bootstrap", "generate-host-token", hostId]);
let hostToken = result.stdout.split("\n").slice(-2)[0];
// Default to localhost on correct port for host in standalone mode
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
await execHost(["config", "set", "host.controller_token", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
}
if (answers.mode === "host") {
@@ -882,11 +961,13 @@ async function main() {
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
}
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode);
await writeScripts(answers.mode, args.selfSignedCert);
}
if (answers.mode === "ctl") {
+62
View File
@@ -0,0 +1,62 @@
"use strict";
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.
// For Windows the command is cmd.exe /d /s /c "command arg arg ..."
// The parsing and escaping on windows is cursed. The following references are not for mortals:
// https://daviddeley.com/autohotkey/parameters/parameters.htm#WIN
// https://stackoverflow.com/a/4095133
// https://ss64.com/nt/syntax-esc.html
if (process.platform === "win32") {
if (!/[\t "%&<>\\^|]/.test(arg)) {
return arg;
}
const escaped = [];
let pos = 0;
while (pos < arg.length) {
if (arg[pos] === "\\") {
// If a backslash is followed by a quote or the end of the string, then
// double the number of backslashes because why not!
let peek = pos + 1;
while (peek !== arg.length && arg[peek] === "\\") {
peek += 1;
}
if (peek === arg.length || arg[peek] === '"') {
const count = peek - pos;
escaped.push("\\".repeat(count * 2));
pos = peek;
continue;
} else {
escaped.push("\\");
}
} else if (arg[pos] === '"') {
escaped.push('""');
} else if (arg[pos] === "%") {
// Expect a pct environment variable to contain %, because cmd does variable substitutions
// before parsing string quotes, and there's no mechanism for escaping % symbols, and this
// is a half sane workaround. The less sane workaround is to replace % with %%cd:~,%.
escaped.push("%pct%");
} else {
escaped.push(arg[pos]);
}
pos += 1;
}
return `"${escaped.join("")}"`;
}
// For all other platforms the command is /bin/sh -c <command arg arg ...>
// The command and its arguments are passed as a single argument to the shell.
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
return arg;
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+5 -7
View File
@@ -3,23 +3,21 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.18",
"version": "2.0.0-alpha.24",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.1.1",
"chalk": "catalog:",
"inquirer": "^8.2.4",
"phin": "^3.7.0",
"yargs": "^17.7.2"
},
"publishConfig": {
+50 -13
View File
@@ -1,8 +1,10 @@
"use strict";
const { logger } = require("./logging");
const fs = require("fs-extra");
const fs = require("node:fs/promises");
const path = require("path");
const _package = require("./package.json");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -59,9 +61,15 @@ async function copyTemplateFile(src, dst, properties) {
return include ? content : "";
});
// Create folder for output file
const dir = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
// Attempt to write the output file, warn if it already exists
try {
await fs.outputFile(dst, outputData, { flag: "wx" });
await fs.writeFile(dst, outputData, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
logger.warn(`Could not create file ${dst} because it already exists`);
@@ -76,8 +84,15 @@ async function copyPluginTemplates(pluginName, templates) {
const files = new Map();
const prepare = [];
// Some basic flags for selecting files to include
const javascriptOnly = templates.includes("js");
const webpack = templates.includes("controller") || templates.includes("web");
const config = !templates.includes("no_config") && templates.some(value => (
["controller", "host", "instance", "ctl"].includes(value)
));
const webpack = config || templates.includes("controller") || templates.includes("web");
// A count of the number of isolated contexts the plugin runs under
let pluginContexts = 0;
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
@@ -85,10 +100,9 @@ async function copyPluginTemplates(pluginName, templates) {
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
// Files and dependencies to support typescript
@@ -96,6 +110,7 @@ async function copyPluginTemplates(pluginName, templates) {
prepare.push("tsc --build");
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
if (webpack) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
@@ -103,56 +118,78 @@ async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --env production");
prepare.push("webpack-cli --mode production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
files.set(`web/index.${ext}x`, path.join(templatePath,
templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x`
));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
} else {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/no_plugin.${ext}x`));
}
}
// Files for the controller
if (templates.includes("controller")) {
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
pluginContexts += 1;
}
// Files for hosts
if (templates.includes("host")) {
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
pluginContexts += 1;
}
// Files for instances
if (templates.includes("instance")) {
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
pluginContexts += 1;
}
// Files for lua modules
if (templates.includes("module")) {
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
files.set("module/module.json", path.join(commonPath, "module/module.json"));
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
if (templates.includes("instance")) {
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
} else {
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
}
}
// Files for the control tool
if (templates.includes("ctl")) {
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
pluginContexts += 1;
}
// If there are more than one contexts then include the message file
if (pluginContexts > 1) {
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
}
// Properties that will control the replacements in the templates
const properties = {
// Weather this is ts or js
typescript: !javascriptOnly,
ext: ext,
// Which templates where requested
multi_context: pluginContexts > 1,
controller: templates.includes("controller"),
host: templates.includes("host"),
instance: templates.includes("instance"),
module: templates.includes("module"),
ctl: templates.includes("ctl"),
web: templates.includes("web"),
plugin_name: pluginName,
// Macro flags for context requirements
webpack: webpack,
config: config,
// String values for package json
clusterio_version: _package.version,
node_version: _package.engines.node,
prepare: prepare.join(" && "),
ext: ext,
plugin_name: pluginName,
};
// Write all the template files
File diff suppressed because it is too large. Load diff
@@ -1,54 +1,93 @@
--[[
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
Most of your code will be within this one file, but you can create more and require them
clusterio_api exposes functions to enable communication with your InstancePlugin
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
]]
local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
--- Top level module table, contains event handlers and public methods
--- @class __plugin_name__
local MyModule = {
events = {},
on_nth_tick = {},
}
--- global is 'synced' between players, you should use your plugin name to avoid conflicts
-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup
local globalData = {}
local function setupGlobalData()
if global["__plugin_name__"] == nil then
global["__plugin_name__"] = {
--[[
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
There are edge cases in the following events: pre player left, player left, console command, and config changed
If you use any of these events and access `script_data` then make sure to call this function first
]]
local script_data = {}
local function setup_script_data()
if compat.script_data["__plugin_name__"] == nil then
compat.script_data["__plugin_name__"] = {
-- starting values go here
}
end
globalData = global["__plugin_name__"]
MyModule.on_load()
end
--- The on_load function is called independently for each client when they first load the map
-- It should be used to restore global aliases and metatables not registered with script.register_metatable
function MyModule.on_load()
script_data = compat.script_data["__plugin_name__"]
end
--- Public methods should be available though your top level module table
function MyModule.foo()
game.print("foo")
game.print("foo: " .. (script_data.counter or 0))
end
--- Private methods should be local to the file, this will prevent others from calling it
local function bar()
game.print("bar")
script_data.counter = (script_data.counter or 0) + 1
game.print("bar: " .. script_data.counter)
end
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_load
MyModule.events[clusterio_api.events.on_server_startup] = function(event)
setupGlobalData()
game.print(game.table_to_json(event))
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
--- @param event EventData.on_server_startup
local function on_server_startup(event)
setup_script_data()
game.print(compat.table_to_json(event))
end
--- Factorio events are accessible through defines.events, you can have one handler per event per module
MyModule.events[defines.events.on_player_crafted_item] = function(event)
game.print(game.table_to_json(event))
--- Triggered every time a player crafts an item, see below for how handlers are registered
--- @param event EventData.on_player_crafted_item
local function on_player_crafted_item(event)
game.print(compat.table_to_json(event))
//%if instance
local player = assert(game.get_player(event.player_index))
clusterio_api.send_json("__plugin_name__-plugin_example_ipc", {
tick = game.tick, player_name = game.get_player(event.player_index).name
tick = game.tick, player_name = player.name
})
//%endif
end
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
MyModule.on_nth_tick[300] = function()
--- Run bar every 5 seconds, see below for how handlers are registered
local function on_nth_tick_300()
game.print(game.tick)
bar()
end
--- Always return the top level module table from control, this is how clusterio will access your event handlers
--- Factorio events are accessible through defines.events, you can have one handler per event per file
local e = defines.events
local events = {
[clusterio_api.events.on_server_startup] = on_server_startup,
[e.on_player_crafted_item] = on_player_crafted_item,
}
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
local on_nth_tick = {
[300] = on_nth_tick_300,
}
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
MyModule.events = events --- @package
MyModule.on_nth_tick = on_nth_tick --- @package
return MyModule
@@ -1,3 +1,9 @@
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
-- luacheck: globals __plugin_name__
--[[
It is best practice to not expose any globals because all modules share a global environment
However, sometimes you need globals, for example to access functions within rcon commands
Therefore, we advise that this should be the only file in your module to expose globals
Typically this would be your control file as shown in the example below
]]
-- Access using `/sc __plugin_name__.foo()`
__plugin_name__ = require("modules/__plugin_name__/control")
@@ -1,3 +1,8 @@
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
-- Access the exports from other modules using require("modules/__plugin_name__")
--[[
It is typical for a module to have a control file as the entry point, although this is not a requirement
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
You are not required to specify any default export, but note that a module can still require yours directly
]]
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
return require("modules/__plugin_name__/control")
+30 -6
View File
@@ -12,15 +12,27 @@
"prepare": "__prepare__"
},
"engines": {
"node": ">=18"
"node": "__node_version__"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.17"
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
//%endif
//%if typescript & web
@@ -32,13 +44,24 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if web | controller // The controller uses webpack even when there is no plugin code
//%if webpack
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
"@clusterio/web_ui": "^2.0.0-alpha.17",
//%endif
"@clusterio/lib": "^2.0.0-alpha.17"
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
@@ -48,6 +71,7 @@
},
"keywords": [
"clusterio",
"clusterio-plugin",
"factorio"
]
}
@@ -1,3 +1,2 @@
dist/
node_modules/
package-lock.json
dist/
@@ -1,3 +1,3 @@
webpack.config.js
dist/web/build
dist/browser
dist/web
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.__ext__x",
output: {
@@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), {
exposes: {
"./": "./index.__ext__",
"./package.json": "./package.json",
//%if web
"./web": "./web/index.__ext__x",
//%endif
},
shared: {
"@clusterio/lib": { import: false },
@@ -1,13 +1,17 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Messages requires multi context
const {
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");
//%endif
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
@@ -15,14 +19,19 @@ class ControllerPlugin extends BaseControllerPlugin {
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));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
// 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
}
//%endif
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
@@ -45,6 +54,7 @@ class ControllerPlugin extends BaseControllerPlugin {
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));
@@ -57,9 +67,11 @@ class ControllerPlugin extends BaseControllerPlugin {
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(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1,6 +1,7 @@
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@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");
/* eslint-disable no-console */
@@ -1,13 +1,17 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%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}`);
@@ -16,6 +20,7 @@ class HostPlugin extends BaseHostPlugin {
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
@@ -28,6 +33,7 @@ class HostPlugin extends BaseHostPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
module.exports = {
+34 -13
View File
@@ -1,24 +1,29 @@
"use strict";
const lib = require("@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
@@ -27,6 +32,7 @@ lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
@@ -34,9 +40,13 @@ const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
//%if controller
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
controllerEntrypoint: "./controller",
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
@@ -46,9 +56,13 @@ const plugin = {
},
},
//%endif
//%if host
//%if host & config // Blank line for formatting
hostEntrypoint: "./host",
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
@@ -58,9 +72,13 @@ const plugin = {
},
},
//%endif
//%if instance
//%if instance & config | module & config // Blank line for formatting
instanceEntrypoint: "./instance",
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
@@ -70,9 +88,13 @@ const plugin = {
},
},
//%endif
//%if ctl
//%if ctl & config // Blank line for formatting
ctlEntrypoint: "./ctl",
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
@@ -82,14 +104,18 @@ const plugin = {
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -97,11 +123,6 @@ const plugin = {
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
module.exports = {
@@ -1,38 +1,56 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%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
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
//%if module
this.sendRcon("/sc __plugin_name__.foo()");
//%endif
}
async handlePluginExampleRequest(request) {
@@ -42,6 +60,7 @@ class InstancePlugin extends BaseInstancePlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event) {
@@ -0,0 +1,11 @@
"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,
};
@@ -1,6 +1,6 @@
import React, {
useContext, useEffect, useState,
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
useCallback, useSyncExternalStore,
//%endif
} from "react";
@@ -10,35 +10,39 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
//%if controller // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
@@ -47,15 +51,24 @@ export class WebPlugin extends BaseWebPlugin {
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
permission: "__plugin_name__.page.view",
// 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",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller & web // Subscribing requires web content and the controller
//%if controller// Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
@@ -63,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
@@ -75,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
@@ -1,12 +1,16 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages";
//%endif
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
@@ -14,21 +18,26 @@ export class ControllerPlugin extends BaseControllerPlugin {
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));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
// 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
}
//%endif
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
@@ -41,9 +50,10 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
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));
@@ -56,9 +66,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
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(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
+2 -2
View File
@@ -1,6 +1,6 @@
type Control = any; // import type { Control } from "@clusterio/ctl";
import { BaseCtlPlugin } from "@clusterio/ctl";
import { BaseCtlPlugin, type Control } 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";
/* eslint-disable no-console */
@@ -1,12 +1,16 @@
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%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}`);
@@ -15,6 +19,7 @@ export class HostPlugin extends BaseHostPlugin {
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -27,4 +32,5 @@ export class HostPlugin extends BaseHostPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
+39 -13
View File
@@ -1,23 +1,28 @@
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,
});
//%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
@@ -26,39 +31,49 @@ 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
declare module "@clusterio/lib" {
//%if controller
//%endif
//%if controller & config
export interface ControllerConfigFields {
"__plugin_name__.myControllerField": string;
}
//%endif
//%if host
//%if host & config
export interface HostConfigFields {
"__plugin_name__.myHostField": string;
}
//%endif
//%if instance
//%if instance & config
export interface InstanceConfigFields {
"__plugin_name__.myInstanceField": string;
}
//%endif
//%if ctl
//%if ctl & config
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
//%if config
}
//%endif
export const plugin: lib.PluginDeclaration = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.ts",
//%if controller
//%// There are a lot of statements here to make the formatting look good with and without 'config'
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
@@ -68,9 +83,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if host
//%if host & config // Blank line for formatting
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
@@ -80,9 +99,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if instance
//%if instance & config | module & config // Blank line for formatting
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
@@ -92,9 +115,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if ctl
//%if ctl & config // Blank line for formatting
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
@@ -104,14 +131,18 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -119,9 +150,4 @@ export const plugin: lib.PluginDeclaration = {
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
@@ -1,6 +1,8 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
//%if module
type PuginExampleIPC = {
@@ -10,32 +12,48 @@ type PuginExampleIPC = {
//%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
this.sendRcon("/sc __plugin_name__.foo()");
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -48,6 +66,7 @@ export class InstancePlugin extends BaseInstancePlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event: PuginExampleIPC) {
@@ -0,0 +1,7 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } 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
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
},
}
@@ -1,11 +1,13 @@
{
"extends": "../../tsconfig.browser.json",
"references": [
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "dist/browser",
}
"lib": ["dom", "es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"target": "es2022",
"jsx": "react-jsx",
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -1,11 +1,13 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
],
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"outDir": "${configDir}/dist/node"
},
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
@@ -10,35 +10,39 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
@@ -49,20 +53,22 @@ export class WebPlugin extends BaseWebPlugin {
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 & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller | !web
permission: undefined, // "__plugin_name__.example.permission.request",
//%if !controller
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
@@ -70,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -82,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
+95 -63
View File
@@ -4,8 +4,8 @@
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
import fs from "fs-extra";
import yargs from "yargs";
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";
@@ -27,8 +27,8 @@ import BaseCtlPlugin from "./src/BaseCtlPlugin";
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
constructor(url: string, maxReconnectDelay: number, token: string) {
super(url, maxReconnectDelay);
this._token = token;
}
@@ -54,8 +54,6 @@ class ControlConnector extends lib.WebSocketClientConnector {
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Certificate authority used to validate TLS connections to the controller. */
tlsCa?: string;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
@@ -64,12 +62,10 @@ export class Control extends lib.Link {
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
tlsCa: string | undefined,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = controlConfig;
this.tlsCa = tlsCa;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
@@ -143,8 +139,30 @@ interface CtlArguments {
pluginList: string,
}
async function startControl() {
yargs
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
ctlPlugins?: Map<string, BaseCtlPlugin>;
rootCommands?: lib.CommandTree;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
ctlPlugins?: Map<string, BaseCtlPlugin>,
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
// If the currently loaded yargs object supports .reset() we use that (older versions),
// otherwise we fall back to creating a brand-new parser instance by invoking the
// yargs factory function directly with an empty argv array. This avoids Mocha’s
// own CLI flags (or anything else that touched yargs earlier in the same process)
// from leaking into subsequent parses inside the test runner.
const parser: Argv = typeof (yargs as any).reset === "function"
? (yargs as any).reset()
: (yargs as any)([]);
parser
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -167,110 +185,123 @@ async function startControl() {
default: "plugin-list.json",
type: "string",
})
.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)
.wrap(yargs.terminalWidth())
.wrap(parser.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
// eslint-disable-next-line node/no-sync
let args = yargs.parseSync() as CtlArguments;
let args = parser.parseSync(argv) as CtlArguments;
// Log stream for the ctl session.
logger.add(
new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new lib.TerminalFormat(),
})
);
lib.handleUnhandledErrors();
// Log stream for the ctl session. Skipped in testing.
if (!noLoggerTransport) {
logger.add(
new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new lib.TerminalFormat(),
})
);
lib.handleUnhandledErrors();
}
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
// Discover and load plugins. This check exists to allow tests to inject plugins.
if (!ctlPlugins || 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 };
}
logger.verbose("Loading Plugins");
ctlPlugins = await loadPlugins(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;
}
logger.verbose("Loading Plugins");
let ctlPlugins = await loadPlugins(pluginList);
// Add all cluster management commands including ones from plugins
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
// Reparse after commands have been added with help and strict checking.
args = yargs
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
args = parser
.help()
.strict()
.parse() as CtlArguments
.parse(argv) as CtlArguments
;
logger.verbose(`Loading config from ${args.config}`);
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 {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control");
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");
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
if (args._.length === 0) {
yargs.showHelp();
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
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, args.config);
return;
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
// Determine which command is being executed.
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
let commandPath = [...args._] as string[];
let targetCommand: lib.CommandTree | lib.Command = rootCommands;
while (commandPath.length && targetCommand instanceof lib.CommandTree) {
targetCommand = targetCommand.get(commandPath.shift()!)!;
}
assert(targetCommand instanceof lib.Command);
return targetCommand;
}
async function startControl() {
const {
args,
shouldRun,
ctlPlugins,
rootCommands,
controlConfig,
} = await initialize(process.argv.slice(2));
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
return;
}
// The remaining commands require connecting to the controller.
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let tlsCa: string | undefined;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
@@ -285,12 +316,13 @@ async function startControl() {
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
try {
const targetCommand = selectTargetCommand(args, rootCommands);
await targetCommand.run(args, control);
} catch (err) {
+10 -12
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.18",
"version": "2.0.0-alpha.24",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -13,28 +13,26 @@
"clusterioctl": "./dist/node/ctl.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.18",
"@clusterio/lib": "workspace:*",
"as-table": "^1.0.55",
"fs-extra": "^11.1.1",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.10.0",
"set-blocking": "catalog:",
"winston": "catalog:",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
}
+4
View File
@@ -0,0 +1,4 @@
export function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
File diff suppressed because it is too large. Load diff
+201
View File
@@ -0,0 +1,201 @@
import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
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());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
},
}));
const controllerConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "controller config management",
});
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());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set <field> [value]", "Set field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
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));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set-prop <field> <prop> [value]", "Set property of field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["edit [editor]", "Edit controller configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { editor: string }, control: Control) {
let config = await control.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, {}
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
controllerCommands.add(controllerConfigCommands);
const controllerPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "controller plugin inspection",
});
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")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
controllerCommands.add(controllerPluginCommands);
+11
View File
@@ -0,0 +1,11 @@
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
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;
},
}));
+305
View File
@@ -0,0 +1,305 @@
import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
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());
print(asTable(hosts));
},
}));
hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, control: Control) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
hostCommands.add(new lib.Command({
definition: ["create-config", "Create host config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
});
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
control: Control
) {
let config = await control.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
let content = JSON.stringify(config, null, "\t");
if (args.output === "-") {
print(content);
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.writeFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
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());
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, 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));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.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>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.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.`);
// 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);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const hostPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "host plugin inspection",
});
hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
}))));
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to update on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to install on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
hostCommands.add(hostPluginCommands);
+661
View File
@@ -0,0 +1,661 @@
import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import os from "os";
import child_process from "child_process";
import stream from "stream";
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";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
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());
print(asTable(list));
},
}));
instanceCommands.add(new lib.Command({
definition: ["create <name>", "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
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(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
const instanceConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Instance config management",
});
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));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let instanceId = await lib.resolveInstance(control, 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));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// If this is from stdin or looks like an array, object or string
// literal throw the parse error, otherwise assume this is a string.
// The resoning behind this is that correctly quoting the string
// with the all the layers of quote removal at play is difficult.
// See the following table for how to pass "That's a \" quote" in
// different environments:
// ----------------------- WARNING -------------------------------
// This table is most likely invalid due a change in yargs parsing
// ---------------------------------------------------------------
// cmd : """""That's a \\"" quote"""""
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
// PowerShell : '"""""That''s a \\"" quote"""""'
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
// bash : '""That'\''s a \" quote""'
// bash + npx : '""That'\''s a \" quote""'
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
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));
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 disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.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.`);
// 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);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new lib.Command({
definition: ["assign <instance> [host]", "Assign instance to a host", (yargs) => {
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));
},
}));
async function loadMapSettings(
args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string},
) {
let seed = args.seed;
let mapGenSettings;
let mapSettings;
if (args.mapExchangeString) {
let parsed = lib.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString());
}
if (args.mapSettings) {
mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString());
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
const instanceSaveCommands = new lib.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
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());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
)));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(
args: {
instance: string,
name: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
},
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
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));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
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));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
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")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
const response = await fetch(url, {
method: "POST",
body: (await fs.open(args.filepath)).createReadStream(),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] };
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.saves && result.saves.length) {
logger.info(`Successfully uploaded as ${result.saves[0]}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading save failed");
}
},
}));
instanceSaveCommands.add(new lib.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(
args: {
sourceInstance: string,
sourceSave: string,
targetInstance: string,
targetSave: string,
copy: boolean,
},
control: Control
) {
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
targetInstanceId,
args.targetSave || args.sourceSave,
args.copy
)
);
print(`Transferred as ${storedName} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
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));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
const filename = await lib.downloadFile(url, args.save, "rename");
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
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));
},
}));
instanceCommands.add(instanceSaveCommands);
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());
},
}));
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());
},
}));
instanceCommands.add(new lib.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"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;
},
}));
instanceCommands.add(new lib.Command({
definition: ["restart <instance>", "Restart instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"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;
},
}));
instanceCommands.add(new lib.Command({
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
yargs.options({
"force": {
describe: "Start instances even if they have exclude_from_start_all set",
nargs: 0,
type: "boolean",
default: false,
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
// Only start instances that are currently stopped
if (instance.status !== "stopped") {
return false;
}
// Respect exclude_from_start_all setting unless --force is used
if (!args.force && instance.excludeFromStartAll) {
return false;
}
return true;
});
if (instancesToStart.length === 0) {
print("No instances to start.");
return;
}
print(`Starting ${instancesToStart.length} instance(s):`);
for (const instance of instancesToStart) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
}
});
await Promise.all(startPromises);
print("Start all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
instance: string,
scenario: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
keepOpen: boolean,
},
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
control.keepOpen = args.keepOpen;
},
}));
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());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
if (instancesToStop.length === 0) {
print("No instances to stop.");
return;
}
print(`Stopping ${instancesToStop.length} instance(s):`);
for (const instance of instancesToStop) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
}
});
await Promise.all(stopPromises);
print("Stop all operation completed.");
},
}));
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());
},
}));
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));
},
}));
instanceCommands.add(new lib.Command({
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
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) },
new lib.InstanceSendRconRequest(args.command),
);
// Factorio includes a newline in its response output.
process.stdout.write(result);
},
}));
+90
View File
@@ -0,0 +1,90 @@
import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Control } from "../ctl";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
yargs.options({
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
});
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
control: Control
) {
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;
},
}));
logCommands.add(new lib.Command({
definition: ["query", "Query cluster log", (yargs) => {
yargs.options({
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Query log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: undefined },
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
all: boolean,
controller: boolean,
host: string | null,
instance: string | null,
maxLevel?: string,
limit: number,
start: boolean,
},
control: Control
) {
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(
args.all,
args.controller,
hostIds,
instanceIds,
args.maxLevel as keyof typeof levels,
args.limit,
args.start ? "asc" : "desc",
));
if (!args.start) {
result.log.reverse();
}
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
format: new lib.TerminalFormat({ showTimestamp: true }),
transports: [
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
],
});
for (let info of result.log) {
stdoutLogger.log(info as any);
}
},
}));
+215
View File
@@ -0,0 +1,215 @@
import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import stream from "stream";
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";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new lib.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
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)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
for (let entry of value) {
print(` ${entry}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modCommands.add(new lib.Command({
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
yargs.options({
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
print(asTable(mods));
},
}));
modCommands.add(new lib.Command({
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
yargs.options({
"page": {
describe: "Result page to show",
type: "number",
default: 1,
},
"page-size": {
describe: "Results per page to show",
type: "number",
default: 10,
},
"sort": {
describe: "sort results by given field",
type: "string",
},
"sort-order": {
describe: "order to sort results in (asc/desc)",
type: "string",
default: "asc",
},
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(
args: {
factorioVersion: string,
query: string,
page: number,
pageSize: number,
sort?: string,
sortOrder: "asc" | "desc",
fields: string[],
},
control: Control
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
args.sortOrder
));
let results = response.results.flatMap(result => result.versions);
if (!args.fields.includes("all")) {
for (let entry of results) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
for (let issue of response.queryIssues) {
print(issue);
}
print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`);
print(asTable(results));
},
}));
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) {
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")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let response = await fetch(url, {
method: "POST",
body: (await fs.open(args.file)).createReadStream(),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: [], request_errors?: [], mods: Parameters<typeof lib.ModInfo.fromJSON>[0][]};
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.mods && result.mods.length) {
const mod = lib.ModInfo.fromJSON(result.mods[0]);
logger.info(`Successfully uploaded ${mod.filename}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading mod failed");
}
},
}));
modCommands.add(new lib.Command({
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
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)) {
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 url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let filename = `${args.name}_${args.modVersion}.zip`;
await lib.downloadFile(url, filename, "overwrite");
logger.info(`Downloaded ${filename}`);
},
}));
modCommands.add(new lib.Command({
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
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)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
+315
View File
@@ -0,0 +1,315 @@
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";
const asTable = asTableModule.configure({ delimiter: " | " });
export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" });
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) {
let mods = new Map(
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
if (field === "mods") {
print(`${field}:`);
for (let [name, entry] of value) {
const mod = mods.get(`${name}_${entry.version}`);
const missing = !mod && "? ";
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
const warn = missing || badChecksum || "";
const enabled = entry.enabled ? "" : "(disabled) ";
print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of settings as any) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else if (field === "exportManifest") {
print(`${field}:`);
if (value && value.assets) {
print(" assets:");
for (let [name, fileName] of Object.entries(value.assets)) {
print(` ${name}: ${fileName}`);
}
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
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());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
print(asTable(modPacks));
},
}));
function setModPackSettings(
modPack: lib.ModPack,
args: {
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
) {
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
modPack.settings[checkedScope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new lib.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new lib.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
doSettings(args.colorSetting || [], value => {
let color = JSON.parse(value);
if (typeof color !== "object" || color === null) {
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
}
return color;
});
}
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new lib.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, enabled: true, version, sha1 });
}
}
function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) {
for (let mod of mods || []) {
if (!modPack.mods.has(mod)) {
throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`);
}
modPack.mods.get(mod)!.enabled = enabled;
}
}
modPackCommands.add(new lib.Command({
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(
args: {
name: string,
factorioVersion: string,
description?: string,
mods?: string[],
disabledMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
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) {
const modPack = lib.ModPack.fromModPackString(args.string);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
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))
);
print(modPack.toModPackString());
},
}));
modPackCommands.add(new lib.Command({
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(
args: {
modPack: string,
name?: string,
description?: string,
factorioVersion?: string,
addMods?: string[],
enableMods?: string[],
disableMods?: string[],
removeMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackModsEnabled(modPack, args.disableMods, false);
setModPackModsEnabled(modPack, args.enableMods, true);
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = args.removeSetting.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
if (modPack.settings[checkedScope].has(name)) {
modPack.settings[checkedScope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await control.send(new lib.ModPackUpdateRequest(modPack));
},
}));
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));
},
}));
+16
View File
@@ -0,0 +1,16 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
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());
print(asTable(permissions));
},
}));
+100
View File
@@ -0,0 +1,100 @@
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";
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());
print(asTable(roles));
},
}));
roleCommands.add(new lib.Command({
definition: ["create <name>", "Create a new role", (yargs) => {
yargs.positional("name", { describe: "Name of role to create", type: "string" });
yargs.options({
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
"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(
args.name,
args.description,
args.permissions,
));
logger.info(`Created role ID ${id}`);
},
}));
roleCommands.add(new lib.Command({
definition: ["edit <role>", "Edit existing role", (yargs) => {
yargs.positional("role", { describe: "Role to edit", type: "string" });
yargs.options({
"name": { describe: "New name for role", nargs: 1, type: "string" },
"description": { describe: "New description for role", nargs: 1, type: "string" },
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
"remove-perms": {
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
},
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: {
role: string,
name?: string,
description?: string,
setPerms?: string[],
addPerms?: string[],
removePerms?: string[],
grantDefault?: boolean,
},
control: Control
) {
let role = await lib.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
}
if (args.description !== undefined) {
role.description = args.description;
}
if (args.addPerms) {
for (const perm of args.addPerms) {
role.permissions.add(perm);
}
}
if (args.removePerms) {
for (let perm of args.removePerms) {
role.permissions.delete(perm);
}
}
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
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));
},
}));
+304
View File
@@ -0,0 +1,304 @@
import fs from "node:fs/promises";
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";
const asTable = asTableModule.configure({ delimiter: " | " });
export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" });
userCommands.add(new lib.Command({
definition: ["show <name>", "Show details for one user", (yargs) => {
yargs.positional("name", { decribe: "Name of user to show", type: "string" });
yargs.options({
"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));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
let instanceStats = user.instanceStats;
// @ts-expect-error Terrible hack
delete user.instanceStats;
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await control.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
return instance.name;
}
return "<deleted>";
}
for (let [id, playerInstanceStats] of instanceStats || []) {
print();
print(`Instance ${instanceName(id)} (${id}):`);
print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value }))));
}
}
},
}));
userCommands.add(new lib.Command({
definition: [["list", "l"], "List user in the cluster", (yargs) => {
yargs.options({
"stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false },
"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());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
}
// @ts-expect-error Terrible hack
delete user.playerStats;
// @ts-expect-error Terrible hack
delete user.isDeleted;
// @ts-expect-error Terrible hack
delete user.banReason;
// @ts-expect-error Terrible hack
delete user.instanceStats;
if (!args.attributes) {
// @ts-expect-error Terrible hack
delete user.isAdmin;
// @ts-expect-error Terrible hack
delete user.isWhitelisted;
// @ts-expect-error Terrible hack
delete user.isBanned;
}
}
print(asTable(users));
},
}));
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));
},
}));
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));
},
}));
userCommands.add(new lib.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
yargs.options({
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
"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));
},
}));
userCommands.add(new lib.Command({
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
yargs.options({
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
"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));
},
}));
userCommands.add(new lib.Command({
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
yargs.options({
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
user: string,
pardon: boolean,
reason: string,
create: boolean
},
control: Control
) {
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
userCommands.add(new lib.Command({
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
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());
let resolvedRoles = [];
for (let roleName of args.roles) {
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
resolvedRoles.push(roleId);
} else {
let found = false;
for (let role of roles) {
if (role.name === roleName) {
resolvedRoles.push(role.id);
found = true;
break;
}
}
if (!found) {
throw new lib.CommandError(`No role named ${roleName}`);
}
}
}
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
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));
},
}));
async function handleImportOrRestore(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, 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]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
const filename = path.basename(args.filepath);
if (args.users || (optionCount === 0 && data.export_version)) {
importType = "users";
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
importType = "bans";
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
importType = "admins";
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
importType = "whitelist";
} else {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
print(`Wrote backup to ${importType}-backup.json`);
}
}
userCommands.add(new lib.Command({
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
userCommands.add(new lib.Command({
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
"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);
},
}));
userCommands.add(new lib.Command({
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
yargs.options({
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
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);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
if (args.users) {
exportType = "users";
} else if (args.bans) {
exportType = "bans";
} else if (args.admins) {
exportType = "admins";
} else if (args.whitelist) {
exportType = "whitelist";
}
await fs.writeFile(args.filepath, JSON.stringify(await control.send(
new lib.UserBulkExportRequest(exportType)
), undefined, "\t"));
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
+58
View File
@@ -0,0 +1,58 @@
import * as lib from "@clusterio/lib";
export async function getEditor(argsEditor: string) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
export async function configToKeyVal(data: string) {
let final: Record<string, string> = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// 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 = "";
}
final[finalIndex] = part;
}
}
return final;
}
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
if (name in disallowedList) {
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") {
value = "";
}
allConfigElements += `${name} = ${value}\n\n`;
}
return allConfigElements;
}
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+38 -35
View File
@@ -12,7 +12,7 @@
* @example
* npx clusteriohost run
*/
import fs from "fs-extra";
import fs from "node:fs/promises";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
@@ -51,13 +51,11 @@ void new lib.Gauge(
export class HostConnector extends lib.WebSocketClientConnector {
constructor(
public hostConfig: lib.HostConfig,
tlsCa: string | undefined,
public pluginInfos: lib.PluginNodeEnvInfo[]
) {
super(
hostConfig.get("host.controller_url"),
hostConfig.get("host.max_reconnect_delay"),
tlsCa
);
}
@@ -113,6 +111,7 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.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("run", "Run host", yargs => {
@@ -120,6 +119,10 @@ async function startHost() {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart this host on failure",
});
yargs.option("recovery", {
type: "boolean", nargs: 0, default: false,
describe: "Start the host in recovery mode with all plugins disabled and controller disconnected",
});
})
.demandCommand(1, "You need to specify a command to run")
.strict()
@@ -144,17 +147,13 @@ async function startHost() {
let command = args._[0];
if (command === "run") {
logger.info(`Starting Clusterio host ${version}`);
if (args.recovery) {
logger.warn("Host recovery mode enabled. Some features will be disabled.");
}
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -167,32 +166,42 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
logger.info(`Loading config from ${args.config}`);
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host");
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");
hostConfig = new lib.HostConfig("host", undefined, hostConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, args.config);
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
// If we get here the command was run
// From this point on the host should run
await hostConfigLock.acquire();
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
await fs.ensureDir("modules");
// If we get here the command was run
await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true });
await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true });
await fs.mkdir("modules", { recursive: true });
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
@@ -219,20 +228,13 @@ async function startHost() {
return;
}
let tlsCa: string | undefined;
let tlsCaPath = hostConfig.get("host.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
let hostConnector = new HostConnector(hostConfig, pluginInfos);
host = new Host(
hostConnector,
args.config,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Host.bootstrap(hostConfig)
);
@@ -242,7 +244,7 @@ async function startHost() {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -255,7 +257,7 @@ async function startHost() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -280,6 +282,7 @@ async function startHost() {
});
await hostConnector.connect();
host.startedAtMs = Date.now();
logger.info("Started host");
}
@@ -293,10 +296,10 @@ I version of clusterio. Expect things to break. I
`
);
startHost().catch(err => {
if (err instanceof lib.AuthenticationFailed) {
logger.fatal(err.message);
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
} else if (err instanceof lib.StartupError) {
} else if (err instanceof lib.StartupError || err.code === "InstallationError") {
logger.fatal(`
+----------------------------------+
| Unable to to start clusteriohost |
+3 -2
View File
@@ -1,4 +1,5 @@
-- Important: Keep this API in sync with modules/clusterio/api.lua
local compat = require("compat")
local api = {}
@@ -48,7 +49,7 @@ function api.send_json(channel, data)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
@@ -58,7 +59,7 @@ function api.send_json(channel, data)
else
local file_no = remote.call("clusterio_api", "get_file_no")
local file_name = "clst_" .. file_no .. ".json"
game.write_file(file_name, data, false, 0)
compat.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
@@ -1,4 +1,10 @@
---------------------------------------------------------------------------------------------------
Version: 2.0.20
Date: 25. 02. 2025
Features:
- Added support for Factorio 2.0 and space-age
- Add compatibility library with support for factorio 0.17 - 2.0
---------------------------------------------------------------------------------------------------
Version: 0.1.2
Features:
- Added support for Factorio 1.1.
+10 -4
View File
@@ -2,16 +2,20 @@
"name": "clusterio_lib",
"variants": [
{
"version": "0.1.0",
"version": "2.0.17",
"factorio_version": "0.17"
},
{
"version": "0.1.1",
"version": "2.0.18",
"factorio_version": "0.18"
},
{
"version": "0.1.2",
"version": "2.0.11",
"factorio_version": "1.1"
},
{
"version": "2.0.20",
"factorio_version": "2.0"
}
],
"title": "Clusterio Library (Alpha)",
@@ -19,6 +23,8 @@
"homepage": "https://github.com/clusterio/clusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
"compat.lua": ["..", "..", "modules", "clusterio", "compat.lua"],
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"],
"json.lua": ["..", "..", "modules", "clusterio", "json.lua"]
}
}
+5 -3
View File
@@ -1,11 +1,13 @@
local function send_json(channel, data)
data = game.table_to_json(data)
data = helpers and helpers.table_to_json(data) or game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
script.on_init(function()
send_json("mod_list", game.active_mods)
for name, mod_setting in pairs(game.mod_setting_prototypes) do
send_json("mod_list", script.active_mods)
local mod_settings = prototypes and prototypes.mod_setting or game.mod_setting_prototypes
for name, mod_setting in pairs(mod_settings) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
send_json("defines", defines)
end)
+9 -6
View File
@@ -1,6 +1,8 @@
-- Important: Keep this API in sync with mod/api.lua
local compat = require("modules/clusterio/compat")
local api = {}
clusterio_patch_number = clusterio_patch_number or 0 --- @type number
api.events = {
on_instance_updated = script.generate_event_name(),
@@ -8,11 +10,11 @@ api.events = {
}
function api.get_instance_name()
return global.clusterio.instance_name
return compat.script_data.clusterio.instance_name
end
function api.get_instance_id()
return global.clusterio.instance_id
return compat.script_data.clusterio.instance_id
end
function api.send_json(channel, data)
@@ -23,7 +25,7 @@ function api.send_json(channel, data)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
@@ -31,9 +33,10 @@ function api.send_json(channel, data)
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
local file_name = "clst_" .. global.clusterio_file_no .. ".json"
game.write_file(file_name, data, false, 0)
local script_data = compat.script_data
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
local file_name = "clst_" .. script_data.clusterio_file_no .. ".json"
compat.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
+221
View File
@@ -0,0 +1,221 @@
--[[
Adds various functions used to ensure compatibly between version including some polyfills.
Implementation Guidelines
- If a function is only available at runtime in one version, then all versions must be made to error.
- Where possible, directly assign the function / table to the member of compat.
- When not possible, then it should replace itself with a direct assignment asap.
- Versions are handled in ascending order covering all supported versions and ending with an error.
]]
local lib_json = require("json")
--- @diagnostic disable: deprecated
--- @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 table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
--- @class LibCompatMt
--- @package
local compat_mt = {}
--- @alias VersionTable { [1]: number, [2]: number, [3]: number }
--- Concert a version string into a version table
--- @param version string
--- @return VersionTable
local function version_to_table(version)
local index = 0
local rtn = { 0, 0, 0 }
for part in version:gmatch("%d+") do
index = index + 1
rtn[index] = tonumber(part)
end
return rtn
end
--- The current version of factorio, the earliest supported is 0.17.69
local current_version_str = "0.17.69"
local current_version = version_to_table(current_version_str)
do
local success, base_version = pcall(function()
return script.active_mods.base
end)
if success then
current_version_str = base_version
current_version = version_to_table(current_version_str)
end
end
--- Returns true if the current version is equal to the provided version
--- @param version string Version to compare against
--- @return boolean # True if versions are equal
function compat.version_eq(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] ~= current_version[i] then
return false
end
end
return true
end
--- Returns true if the current version is greater than or equal to the the provided version
--- @param version string Version to compare against
--- @return boolean # True if the version is greater or equal to provided
function compat.version_ge(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] > current_version[i] then
return false
elseif version_tbl[i] < current_version[i] then
return true
end
end
return true
end
--- Returns true if the current version is less than or equal to the the provided version
--- @param version string Version to compare against
--- @return boolean # True if the version is less or equal to provided
function compat.version_le(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] < current_version[i] then
return false
elseif version_tbl[i] > current_version[i] then
return true
end
end
return true
end
--- The major versions of factorio we support
local v2 = compat.version_ge("2.0")
local v1 = compat.version_ge("1.0") and not compat.version_ge("2.0")
local v0 = compat.version_ge("0.17") and not compat.version_ge("1.0")
--- The minor versions of factorio we support
local v2_0 = compat.version_ge("2.0")
local v1_1 = compat.version_ge("1.1") and not compat.version_ge("2.0")
local v1_0 = compat.version_ge("1.0") and not compat.version_ge("1.1")
local v0_18 = compat.version_ge("0.18") and not compat.version_ge("1.0")
local v0_17 = compat.version_ge("0.17") and not compat.version_ge("0.18")
--- Raise an error because the current version is unsupported
local function unsupported_version()
error("Unsupported version: " .. current_version_str, 3)
end
--- Raise an error because the function is only available at runtime
local function runtime_only()
error("To maintain compatibly, this value can only be used during runtime.", 3)
end
--- Keys of this table will raise an unsupported error when accessed
local unsupported_properties = {}
--- Keys of this table can only be accessed at runtime
--- The value is a function which returns the real value
local runtime_properties = {}
--- Handles the indexing of the tables above
function compat_mt:__index(key)
if unsupported_properties[key] then
return unsupported_version()
elseif runtime_properties[key] then
if game == nil then
return runtime_only()
end
local value = runtime_properties[key]()
if value == nil then
return unsupported_version()
end
rawset(self, key, value)
return value
end
end
--- Prototype data contains all the definitions for the game, it is only available during runtime
function runtime_properties.prototypes()
if v0 or v1 then
--- Lazy loading of prototype data
--- Iteration is UB, we can fix this once someone actually needs it
return setmetatable({}, {
__index = function(self, key)
local value = game[key .. "_prototypes"]
rawset(self, key, value)
return value
end
})
elseif v2 then
return prototypes
end
end
--- List of all active mods
function runtime_properties.active_mods()
if v0_17 then
return game.active_mods
elseif v0 or v1 or v2 then
return script.active_mods
end
end
--- Writes a string to file
function runtime_properties.write_file()
if v0 or v1 then
return game.write_file
elseif v2 then
return helpers.write_file
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)
if game then
compat.table_to_json = game.table_to_json
return game.table_to_json(tbl)
end
return lib_json.encode(tbl)
end
compat.json_to_table = function(json)
if game then
compat.json_to_table = game.json_to_table
return game.json_to_table(json)
end
return lib_json.decode(json)
end
elseif v2 then
compat.table_to_json = helpers.table_to_json
compat.json_to_table = helpers.json_to_table
else
unsupported_properties.table_to_json = true
unsupported_properties.json_to_table = true
end
--- Script data refers to lua data which is persisted between loads
--- This is called immediately, during init and during load
local function set_script_data()
if v0 or v1 then
--- @diagnostic disable-next-line
compat.script_data = global
elseif v2 then
compat.script_data = storage
else
unsupported_properties.script_data = true
end
end
set_script_data()
compat.on_init = set_script_data --- @package
compat.on_load = set_script_data --- @package
return setmetatable(compat, compat_mt)
+39 -11
View File
@@ -1,10 +1,14 @@
local api = require('modules/clusterio/api')
local compat = require("modules/clusterio/compat")
--- @class (exact) EventData.on_server_startup:EventData
local function check_patch()
if global.clusterio_patch_number ~= clusterio_patch_number then
global.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {})
if compat.script_data.clusterio_patch_number ~= clusterio_patch_number then
compat.script_data.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {
name = api.events.on_server_startup, tick = game.tick
})
end
end
@@ -14,8 +18,8 @@ impl.events = {}
impl.events[defines.events.on_tick] = check_patch
impl.events[api.events.on_server_startup] = function()
if not global.clusterio then
global.clusterio = {
if not compat.script_data.clusterio then
compat.script_data.clusterio = {
instance_id = nil,
instance_name = nil,
}
@@ -46,12 +50,35 @@ impl.events[defines.events.on_player_left_game] = function(event)
})
end
impl.events[defines.events.on_player_banned] = function(event)
api.send_json("player_event", {
type = "BAN",
name = event.player_name,
reason = event.reason,
})
end
impl.events[defines.events.on_player_unbanned] = function(event)
api.send_json("player_event", { type = "UNBANNED", name = event.player_name })
end
impl.events[defines.events.on_player_promoted] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "PROMOTE", name = player.name })
end
impl.events[defines.events.on_player_demoted] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "DEMOTE", name = player.name })
end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
check_patch()
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
local script_data = compat.script_data
script_data.clusterio.instance_id = new_id
script_data.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
instance_id = new_id,
instance_name = new_name,
@@ -71,16 +98,17 @@ remote.add_interface('clusterio_api', {
end,
get_instance_id = function()
return global.clusterio.instance_id
return compat.script_data.clusterio.instance_id
end,
get_instance_name = function()
return global.clusterio.instance_name
return compat.script_data.clusterio.instance_name
end,
get_file_no = function()
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
return global.clusterio_file_no
local script_data = compat.script_data
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
return script_data.clusterio_file_no
end,
})
Loaded 100 of 397 files, more files were not shown because too many files have changed in this diff. Show more