mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
188
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0d453159 | ||
|
|
a4e4621eaa | ||
|
|
4337555ea1 | ||
|
|
9018397b09 | ||
|
|
1c9aae5909 | ||
|
|
6489fa4048 | ||
|
|
22850b7cd0 | ||
|
|
f65c8fb9a0 | ||
|
|
3232da71e2 | ||
|
|
c9fc4f1c8f | ||
|
|
0c56c56f74 | ||
|
|
2928b9b7c5 | ||
|
|
1fc6971278 | ||
|
|
2679851d66 | ||
|
|
ab86cd2b1c | ||
|
|
02fce63e11 | ||
|
|
8981a018b0 | ||
|
|
bbf5df6d01 | ||
|
|
6f68cf74a7 | ||
|
|
07f501a67a | ||
|
|
4caf06aafc | ||
|
|
4901d76ebe | ||
|
|
6b0dd4e9cf | ||
|
|
a2c38ad4e8 | ||
|
|
438ccbdc7f | ||
|
|
d6c5430e0c | ||
|
|
d48b731ea4 | ||
|
|
bccb6527a7 | ||
|
|
00cf0f59ec | ||
|
|
3a823bee01 | ||
|
|
98650b9bfb | ||
|
|
a781af5b6a | ||
|
|
f202a0975a | ||
|
|
ea4280d184 | ||
|
|
c519ee1389 | ||
|
|
c61b9dbe60 | ||
|
|
77f4aa6296 | ||
|
|
86a9084d56 | ||
|
|
43def44136 | ||
|
|
8c1be7210e | ||
|
|
24e290746d | ||
|
|
d52931a326 | ||
|
|
73cc019d2f | ||
|
|
41151d47f0 | ||
|
|
3fbd9a995d | ||
|
|
af299bc08d | ||
|
|
93fa12200f | ||
|
|
35f769ad25 | ||
|
|
732cee4837 | ||
|
|
681e78967f | ||
|
|
d2bd1382ba | ||
|
|
c0ec9a3890 | ||
|
|
4221b96431 | ||
|
|
3ddbe2ddb3 | ||
|
|
7ceb786143 | ||
|
|
174d9d5b07 | ||
|
|
a1118d08dd | ||
|
|
379d2575a0 | ||
|
|
e30dcc6fff | ||
|
|
281a5ed97e | ||
|
|
6004f861fe | ||
|
|
174c798d4b | ||
|
|
14b8b5ac61 | ||
|
|
89dec572bb | ||
|
|
f662880c8d | ||
|
|
90cd2770b3 | ||
|
|
69240e7917 | ||
|
|
e1d9c1e414 | ||
|
|
376eaeba52 | ||
|
|
80983662aa | ||
|
|
9840f22688 | ||
|
|
b9e2acaaa5 | ||
|
|
7ac8cd3781 | ||
|
|
7e2f10e842 | ||
|
|
256407b9fd | ||
|
|
7979dfaa16 | ||
|
|
10a87d47da | ||
|
|
59709280da | ||
|
|
81b656f0ab | ||
|
|
f966c5b965 | ||
|
|
56a26848e6 | ||
|
|
28f21bfb70 | ||
|
|
7b4724845f | ||
|
|
2053423864 | ||
|
|
e7519975ec | ||
|
|
7b7389526b | ||
|
|
a464b61d39 | ||
|
|
88ca4563a8 | ||
|
|
449bd68fab | ||
|
|
59256e77d1 | ||
|
|
f11d91bd6b | ||
|
|
e0278d0331 | ||
|
|
2baa2e50ac | ||
|
|
02d14bd98d | ||
|
|
952ae21958 | ||
|
|
50d7d28054 | ||
|
|
2896ec1c15 | ||
|
|
6b81e6383d | ||
|
|
5ed7163ed8 | ||
|
|
df1edec623 | ||
|
|
a5bf8d92d9 | ||
|
|
af95ca587b | ||
|
|
331fc7d947 | ||
|
|
5b56aa3fea | ||
|
|
56479246ed | ||
|
|
773c98b31a | ||
|
|
4f6abca9d7 | ||
|
|
0bbaef4751 | ||
|
|
3ff80119e6 | ||
|
|
63e30dd0ee | ||
|
|
6e01ba18c5 | ||
|
|
a39911d667 | ||
|
|
4c5ab21c64 | ||
|
|
cca1d4efc0 | ||
|
|
78d4217a2a | ||
|
|
25a5a2257c | ||
|
|
0f8075ad41 | ||
|
|
e089e2df4c | ||
|
|
cd20464952 | ||
|
|
cb23428507 | ||
|
|
f285d7d803 | ||
|
|
117713eda3 | ||
|
|
515093abd2 | ||
|
|
726a3538c2 | ||
|
|
17db53403a | ||
|
|
c650f42276 | ||
|
|
6989fbdd44 | ||
|
|
9f0633abbb | ||
|
|
4e6f7c5402 | ||
|
|
0adb45bba4 | ||
|
|
be071be8f2 | ||
|
|
7cf13a8b3b | ||
|
|
90de1ecbf0 | ||
|
|
405af0e173 | ||
|
|
5e9beb0cf6 | ||
|
|
0eb56b1711 | ||
|
|
42ad7f8704 | ||
|
|
ff35b5c378 | ||
|
|
c120443657 | ||
|
|
96c5f33dbe | ||
|
|
cf9f087cba | ||
|
|
47c5e0fabd | ||
|
|
a9def876e8 | ||
|
|
eafbc32d3d | ||
|
|
bbf65dea57 | ||
|
|
fcd3dbb65b | ||
|
|
f21a3f45bf | ||
|
|
1711271ca8 | ||
|
|
25e1398229 | ||
|
|
2d62ff6689 | ||
|
|
25099c03ba | ||
|
|
c6f10e2195 | ||
|
|
1f5f28c820 | ||
|
|
a79b129b0c | ||
|
|
e841aa5685 | ||
|
|
35fef5e021 | ||
|
|
de8a7ef884 | ||
|
|
f3a62e585f | ||
|
|
2936973a75 | ||
|
|
d50230becc | ||
|
|
600a5d5ce1 | ||
|
|
81fb223d5e | ||
|
|
0e6d33aa82 | ||
|
|
3bf0d48641 | ||
|
|
3fae97ac8b | ||
|
|
42cd099eea | ||
|
|
916c48844a | ||
|
|
ce85149b4c | ||
|
|
0055f28f45 | ||
|
|
1d5040dc0c | ||
|
|
60ee4b2c86 | ||
|
|
7cb191d332 | ||
|
|
c50298d408 | ||
|
|
aeac7eedec | ||
|
|
2ae3f9a600 | ||
|
|
05790966db | ||
|
|
a60a3bcbd7 | ||
|
|
61a241b02d | ||
|
|
e1c29bcde1 | ||
|
|
7f4159b6c6 | ||
|
|
4080c1025d | ||
|
|
64e429c5f6 | ||
|
|
24c07c0ed1 | ||
|
|
61249e5e7e | ||
|
|
a2a3bcd4f8 | ||
|
|
71c1cffcac | ||
|
|
a769c3179a | ||
|
|
6180a0b27b |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"image":"mcr.microsoft.com/devcontainers/universal:2",
|
||||
"postCreateCommand": "wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && pnpm install",
|
||||
"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 +0,0 @@
|
||||
dist/
|
||||
@@ -12,16 +12,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
factorio-version: [1.1.110]
|
||||
# Supported versions are latest stable/experimental and one minor version before stable (even if from a previous major)
|
||||
# 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@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -31,13 +29,13 @@ jobs:
|
||||
- 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' || matrix.factorio-version != '1.1.110' }}
|
||||
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.node-version == '18.x' && matrix.factorio-version == '1.1.110' }}
|
||||
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' && matrix.factorio-version == '1.1.110' }}
|
||||
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
@@ -48,11 +46,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
|
||||
@@ -61,11 +57,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- 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
|
||||
with:
|
||||
version: 8
|
||||
- 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
-6
@@ -29,16 +29,13 @@ dist/
|
||||
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
/factorio
|
||||
!/test/file/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/
|
||||
|
||||
@@ -31,6 +31,97 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## 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
|
||||
|
||||
@@ -232,7 +232,7 @@ 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.
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ async function removeBuildArtifacts() {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +84,10 @@ async function removeExecutionArtifacts() {
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs
|
||||
await yargs(process.argv.slice(2))
|
||||
.scriptName("clean")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("dry", {
|
||||
.option("dry-run", {
|
||||
alias: "dry",
|
||||
nargs: 0,
|
||||
describe: "Will not delete any files",
|
||||
default: false,
|
||||
@@ -105,7 +108,7 @@ async function main() {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.demandCommand(1, "A command must be specified")
|
||||
.strict()
|
||||
.parse();
|
||||
}
|
||||
|
||||
@@ -239,6 +239,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
+276
-187
@@ -1,118 +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",
|
||||
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "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"],
|
||||
@@ -120,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",
|
||||
@@ -148,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",
|
||||
@@ -178,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",
|
||||
@@ -190,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",
|
||||
@@ -207,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",
|
||||
@@ -232,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",
|
||||
@@ -256,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",
|
||||
@@ -268,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",
|
||||
@@ -289,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",
|
||||
@@ -299,6 +272,7 @@ module.exports = {
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
@@ -308,6 +282,7 @@ module.exports = {
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
@@ -324,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",
|
||||
@@ -342,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/"])]);
|
||||
+21
-16
@@ -2,10 +2,11 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
|
||||
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
|
||||
"test": "pnpm run build && 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",
|
||||
@@ -27,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -41,21 +42,25 @@
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@stylistic/eslint-plugin": "^5.2.2",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@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.21.2",
|
||||
"form-data": "^4.0.2",
|
||||
"express": "^5.1.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.7.3",
|
||||
"yargs": "^17.7.2"
|
||||
"globals": "^16.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"mocha": "^11.7.1",
|
||||
"nyc": "^17.1.0",
|
||||
"phin": "^3.7.1",
|
||||
"typedoc": "^0.28.9",
|
||||
"typescript": "^5.9.2",
|
||||
"yargs": "^18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -127,12 +127,23 @@ 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");
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(controllerConfig, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
|
||||
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;
|
||||
@@ -144,10 +155,11 @@ async function handleBootstrapCommand(
|
||||
admin = userManager.createUser(args.name);
|
||||
}
|
||||
|
||||
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
|
||||
let adminRole = lib.ensureDefaultAdminRole(roles);
|
||||
admin.roleIds.add(adminRole.id);
|
||||
admin.isAdmin = true;
|
||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
await controllerConfigLock.release();
|
||||
|
||||
} else if (subCommand === "generate-user-token") {
|
||||
let user = userManager.getByName(args.name);
|
||||
@@ -210,7 +222,6 @@ interface InitializeParameters {
|
||||
shouldRun: boolean;
|
||||
clusterLogger: winston.Logger;
|
||||
pluginInfos: lib.PluginNodeEnvInfo[] | null;
|
||||
controllerConfigPath: string | null;
|
||||
controllerConfig: lib.ControllerConfig | null;
|
||||
}
|
||||
|
||||
@@ -244,6 +255,7 @@ 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 => {
|
||||
@@ -270,6 +282,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" });
|
||||
})
|
||||
@@ -313,6 +330,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -327,7 +347,6 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
shouldRun,
|
||||
clusterLogger,
|
||||
pluginInfos: null,
|
||||
controllerConfigPath: null,
|
||||
controllerConfig: null,
|
||||
};
|
||||
}
|
||||
@@ -338,46 +357,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,
|
||||
};
|
||||
@@ -393,28 +415,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.userManager.users.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);
|
||||
}
|
||||
|
||||
@@ -427,7 +456,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 HostInfo } from "./src/HostInfo";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
export { default as WsServer } from "./src/WsServer";
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.20",
|
||||
"version": "2.0.0-alpha.22b",
|
||||
"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": {
|
||||
@@ -17,45 +17,45 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"express": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.0",
|
||||
"express": "^4.21.2",
|
||||
"finalhandler": "^1.3.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.18.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@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.9",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/express": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.29.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-middleware": "^6.1.1",
|
||||
"webpack-merge": "^5.9.0"
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@swc/core": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-merge": "catalog:",
|
||||
"webpack-dev-middleware": "^6.1.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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,
|
||||
@@ -78,7 +78,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) {
|
||||
|
||||
@@ -70,6 +70,7 @@ 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.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
@@ -98,6 +99,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,7 +120,13 @@ 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));
|
||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -249,6 +259,14 @@ 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);
|
||||
}
|
||||
@@ -297,7 +315,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")
|
||||
@@ -514,6 +532,217 @@ 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;
|
||||
}
|
||||
|
||||
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 builtInModNames = new Set(_builtInModNames);
|
||||
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 incompatible = new Set<string>();
|
||||
const invalid = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
while (searchQueue.length > 0) {
|
||||
const mod = searchQueue.shift()!;
|
||||
if (mod.incompatible) {
|
||||
incompatible.add(mod.name);
|
||||
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);
|
||||
invalid.add(mod.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Do not add dependencies for invalids, optionals, skips, or valid candidates
|
||||
const existingCandidate = candidateReleases.get(mod.name);
|
||||
if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.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 modReleases = await fetchModReleases(mod.name);
|
||||
if (modReleases && modReleases.releases) {
|
||||
const release = modReleases.releases
|
||||
.filter(info => (
|
||||
info.info_json.factorio_version === factorioVersion
|
||||
&& versionRange.testVersion(info.version)
|
||||
))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
// We only select the mod portal release if it is more recent than the local candidate
|
||||
if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) {
|
||||
candidate = lib.ModInfo.fromJSON({
|
||||
...release.info_json, // Also includes dependences
|
||||
sha1: release.sha1,
|
||||
version: release.version,
|
||||
name: modReleases.name,
|
||||
author: modReleases.owner,
|
||||
title: modReleases.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
return new lib.ModDependencyResolveRequest.Response(
|
||||
[...candidateReleases.values()],
|
||||
[...invalid.values(), ...[...incompatible.values()].filter(mod => (
|
||||
candidateReleases.has(mod) || builtInModNames.has(mod)
|
||||
))],
|
||||
[...dependencyRequirements.keys()].filter(mod => (
|
||||
!candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod)
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
await this._controller.modStore.deleteMod(request.name, request.version);
|
||||
}
|
||||
@@ -573,22 +802,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`);
|
||||
}
|
||||
@@ -596,27 +824,29 @@ 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);
|
||||
|
||||
this._controller.userManager.dirty = true;
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.roleIds.delete(id)) {
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
@@ -668,7 +898,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -752,6 +982,238 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
|
||||
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.userManager.users.values()) {
|
||||
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.userManager.users.values()) {
|
||||
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.userManager.users.values()) {
|
||||
if (user[prop] && !expected.has(user.name)) {
|
||||
user[prop] = false;
|
||||
updated.set(user.id, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A large number of small if statements are unavoidable when reducing the number of updates sent
|
||||
|
||||
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
|
||||
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
|
||||
const updated = new Map<ControllerUser["id"], ControllerUser>();
|
||||
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
|
||||
const getUserOrCreate = (username: string) => {
|
||||
const user = this._controller.userManager.getByName(username);
|
||||
if (user) { return user; }
|
||||
this.user.checkPermission("core.user.create");
|
||||
return this._controller.userManager.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.usersUpdated(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.userManager.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.userManager.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.userManager.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.userManager.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) {
|
||||
@@ -761,6 +1223,10 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.connector._socket!.clusterio_ignore_dump = true;
|
||||
this._controller.debugEvents.on("message", this.ws_dumper);
|
||||
}
|
||||
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ControlConnection;
|
||||
@@ -6,7 +6,7 @@ import type { Static } from "@sinclair/typebox";
|
||||
import finalhandler from "finalhandler";
|
||||
|
||||
import compression from "compression";
|
||||
import events, { EventEmitter } from "events";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
@@ -42,6 +42,14 @@ const logSizeGauge = new Gauge(
|
||||
|
||||
type InstanceId = { instanceId: number };
|
||||
|
||||
type ControllerDebugEvents = {
|
||||
"message": [ { direction: "in" | "out", content: string } ],
|
||||
};
|
||||
|
||||
type ControllerEvents = {
|
||||
"stop": [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all controller related operations
|
||||
* @alias module:controller/src/Controller
|
||||
@@ -50,7 +58,6 @@ export default class Controller {
|
||||
clusterLogger: winston.Logger;
|
||||
/** Array of plugin info objects for known plugins */
|
||||
pluginInfos: lib.PluginNodeEnvInfo[];
|
||||
configPath: string;
|
||||
/** Controller config. */
|
||||
config: lib.ControllerConfig;
|
||||
app: Application;
|
||||
@@ -67,8 +74,8 @@ export default class Controller {
|
||||
wsServer: WsServer;
|
||||
router = new ControllerRouter(this);
|
||||
trustedProxies: BlockList;
|
||||
debugEvents: events.EventEmitter = new events.EventEmitter();
|
||||
private _events: events.EventEmitter = new events.EventEmitter();
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions = new lib.SubscriptionController();
|
||||
@@ -96,13 +103,19 @@ export default class Controller {
|
||||
clusterLogIndex: lib.LogIndex | null = null;
|
||||
clusterLogBuildInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Cache for mod portal requests
|
||||
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
|
||||
// Cache for factorio versions
|
||||
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.ensureDir(databaseDirectory);
|
||||
|
||||
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "systems.json"),
|
||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo)
|
||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
||||
this.migrateSystems,
|
||||
).bootstrap());
|
||||
|
||||
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
@@ -124,20 +137,32 @@ export default class Controller {
|
||||
|
||||
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "mod-packs.json"),
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack)
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack),
|
||||
this.migrateModPacks,
|
||||
).bootstrap());
|
||||
|
||||
if (modPacks.size === 0) {
|
||||
modPacks.setMany(lib.ModPack.defaultModPacks);
|
||||
}
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
this.migrateRoles,
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(config);
|
||||
const userManager = new UserManager(config, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
|
||||
// Add default mod packs
|
||||
if (modPacks.size === 0) {
|
||||
modPacks.setMany(lib.ModPack.defaultModPacks);
|
||||
}
|
||||
|
||||
// Add default roles
|
||||
lib.ensureDefaultAdminRole(roles);
|
||||
lib.ensureDefaultPlayerRole(roles);
|
||||
|
||||
return [
|
||||
systems,
|
||||
hosts,
|
||||
@@ -145,6 +170,7 @@ export default class Controller {
|
||||
saves,
|
||||
modPacks,
|
||||
modStore,
|
||||
roles,
|
||||
userManager,
|
||||
] as const;
|
||||
}
|
||||
@@ -152,7 +178,6 @@ export default class Controller {
|
||||
constructor(
|
||||
clusterLogger: winston.Logger,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
configPath: string,
|
||||
config: lib.ControllerConfig,
|
||||
|
||||
/**
|
||||
@@ -160,6 +185,11 @@ export default class Controller {
|
||||
* will restart the controller on non-zero exit codes.
|
||||
*/
|
||||
public canRestart: boolean = false,
|
||||
/**
|
||||
* If true indicates the controller is in recovery mode and should
|
||||
* disable certain actions such as loading plugins or connecting to hosts
|
||||
*/
|
||||
public recoveryMode: boolean = false,
|
||||
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
|
||||
/** Mapping of host id to host info */
|
||||
public hosts = new lib.SubscribableDatastore<HostInfo>(),
|
||||
@@ -171,12 +201,13 @@ export default class Controller {
|
||||
public modPacks = new lib.SubscribableDatastore<lib.ModPack>(),
|
||||
/** Mods stored on the controller */
|
||||
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
|
||||
/** Mapping of mod pack id to mod pack */
|
||||
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
||||
/** User and roles manager for the cluster */
|
||||
public userManager = new UserManager(config),
|
||||
public userManager = new UserManager(config, roles),
|
||||
) {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.configPath = configPath;
|
||||
this.config = config;
|
||||
|
||||
this.app = express();
|
||||
@@ -200,6 +231,7 @@ export default class Controller {
|
||||
this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
|
||||
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
@@ -207,6 +239,7 @@ export default class Controller {
|
||||
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
|
||||
this.saves.on("update", this.savesUpdated.bind(this));
|
||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||
this.roles.on("update", this.rolesUpdated.bind(this));
|
||||
}
|
||||
|
||||
async start(args: ControllerArgs) {
|
||||
@@ -288,7 +321,7 @@ export default class Controller {
|
||||
let tls_cert = this.config.get("controller.tls_certificate");
|
||||
let tls_key = this.config.get("controller.tls_private_key");
|
||||
|
||||
if (httpsPort && (!tls_cert || !tls_key)) {
|
||||
if (httpsPort && (!tls_cert || !tls_key)) {
|
||||
throw new lib.StartupError(
|
||||
"tls_certificate and tls_private_key must be configure in order to use https_port"
|
||||
);
|
||||
@@ -369,13 +402,12 @@ export default class Controller {
|
||||
|
||||
async _startDevServer(args: ControllerArgs) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
const webpack = require("webpack");
|
||||
const webpackDevMiddleware = require("webpack-dev-middleware");
|
||||
const webpackConfigs = [];
|
||||
|
||||
if (args.dev) {
|
||||
// eslint-disable-next-line node/no-missing-require
|
||||
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
@@ -391,7 +423,7 @@ export default class Controller {
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
|
||||
const compiler = webpack(webpackConfigs);
|
||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
||||
@@ -520,7 +552,8 @@ export default class Controller {
|
||||
}
|
||||
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
||||
}
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart));
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired));
|
||||
const newMetrics = await Promise.all(requests);
|
||||
for (const metric of newMetrics) {
|
||||
this.systems.set(metric);
|
||||
@@ -530,11 +563,55 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let packageName = "@clusterio/controller";
|
||||
try {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("controller.version");
|
||||
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
|
||||
const packageJson = await fs.readJSON(packageJsonPath);
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second check plugin versions
|
||||
for (const pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
|
||||
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onAutosaveIntervalChanged() {
|
||||
if (this.autosaveInterval) {
|
||||
clearInterval(this.autosaveInterval);
|
||||
this.autosaveInterval = undefined;
|
||||
}
|
||||
if (this.recoveryMode) {
|
||||
logger.warn("Recovery | autosaving disabled");
|
||||
return;
|
||||
}
|
||||
const autosaveIntervalSeconds = this.config.get("controller.autosave_interval");
|
||||
if (autosaveIntervalSeconds > 0) {
|
||||
this.autosaveInterval = setInterval(this.saveData.bind(this), autosaveIntervalSeconds * 1000);
|
||||
@@ -560,17 +637,14 @@ export default class Controller {
|
||||
});
|
||||
|
||||
private async _saveDataInternal() {
|
||||
if (this.config.dirty) {
|
||||
this.config.dirty = false;
|
||||
await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t"));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.config.save(),
|
||||
this.systems.save(),
|
||||
this.hosts.save(),
|
||||
this.instances.save(),
|
||||
this.saves.save(),
|
||||
this.modPacks.save(),
|
||||
this.roles.save(),
|
||||
]);
|
||||
|
||||
if (this.userManager.dirty) {
|
||||
@@ -580,10 +654,23 @@ export default class Controller {
|
||||
await lib.invokeHook(this.plugins, "onSaveData");
|
||||
}
|
||||
|
||||
static migrateSystems(rawJson: unknown[]): Static<typeof lib.SystemInfo.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.SystemInfo.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
if (!json.canRestart) { // Added in 2.0.0.alpha.17
|
||||
json.canRestart = false;
|
||||
}
|
||||
if (!json.restartRequired) { // Added in 2.0.0.alpha.21
|
||||
json.restartRequired = false;
|
||||
}
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
|
||||
let serialized = rawJson as any;
|
||||
|
||||
// migrate from pre alpha.19 format
|
||||
// New format 2.0.0.alpha.19
|
||||
if (serialized.length && serialized[0] instanceof Array) {
|
||||
serialized = serialized.map((e: any) => e[1]);
|
||||
}
|
||||
@@ -602,7 +689,7 @@ export default class Controller {
|
||||
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
if (!json.config) { // migrate: from pre Alpha 14 format.
|
||||
if (!json.config) { // New format 2.0.0.alpha.14
|
||||
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
|
||||
}
|
||||
return json;
|
||||
@@ -618,6 +705,31 @@ export default class Controller {
|
||||
return instance;
|
||||
}
|
||||
|
||||
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
for (const mod of json.mods) {
|
||||
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
}
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
json.permissions = json.permissions.map(permission => {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
if (permission === "core.instance.save.list.subscribe") {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
});
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
|
||||
let manifest = {};
|
||||
try {
|
||||
@@ -1021,27 +1133,34 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected control clients with the given role that the
|
||||
* permissions may have changed.
|
||||
* @param role - Role permisions updated for.
|
||||
*/
|
||||
rolePermissionsUpdated(role: lib.Role) {
|
||||
for (let controlConnection of this.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
controlConnection.send(
|
||||
new lib.AccountUpdateEvent(
|
||||
[...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
}))
|
||||
)
|
||||
);
|
||||
rolesUpdated(roles: lib.Role[]) {
|
||||
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
|
||||
// lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
|
||||
// Notify connected control clients with the given role that the permissions may have changed.
|
||||
for (const role of roles) {
|
||||
for (let controlConnection of this.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
controlConnection.send(
|
||||
new lib.AccountUpdateEvent(
|
||||
[...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleRoleSubscription(request: lib.SubscriptionRequest) {
|
||||
const roles = [...this.roles.values()].filter(
|
||||
role => role.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
try {
|
||||
@@ -1051,7 +1170,12 @@ export default class Controller {
|
||||
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
|
||||
}
|
||||
|
||||
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
|
||||
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.recoveryMode) {
|
||||
logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
|
||||
import type UserManager from "./UserManager";
|
||||
|
||||
import {
|
||||
IControllerUser, PermissionError, PlayerStats,
|
||||
Role, SubscribableDatastore, User, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
export default class ControllerUser extends User implements IControllerUser {
|
||||
constructor(
|
||||
public userManager: UserManager,
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof User>
|
||||
@@ -17,7 +20,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
@@ -26,7 +29,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
userManager,
|
||||
controllerRoles,
|
||||
json.token_valid_after,
|
||||
json.name,
|
||||
roleIds,
|
||||
@@ -57,7 +60,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this.userManager.roles.get(id)
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
@@ -79,7 +82,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
}
|
||||
|
||||
for (let roleId of this.roleIds) {
|
||||
let role = this.userManager.roles.get(roleId);
|
||||
let role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default class HostConnection extends BaseConnection {
|
||||
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);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class InstanceInfo {
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: string,
|
||||
public factorioVersion?: lib.TargetVersion,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
@@ -23,7 +23,7 @@ export default class InstanceInfo {
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
@@ -45,6 +45,7 @@ export default class InstanceInfo {
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import ControllerUser from "./ControllerUser";
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
roles: Map<lib.Role["id"], lib.Role> = new Map();
|
||||
users: Map<ControllerUser["id"], ControllerUser> = new Map();
|
||||
dirty = false;
|
||||
|
||||
@@ -20,7 +19,8 @@ export default class UserManager {
|
||||
onlineUsers = new Set<ControllerUser>();
|
||||
|
||||
constructor(
|
||||
private _config: lib.ControllerConfig
|
||||
private _config: lib.ControllerConfig,
|
||||
private _roles: lib.SubscribableDatastore<lib.Role>,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -31,14 +31,15 @@ export default class UserManager {
|
||||
async load(filePath: string): Promise<void> {
|
||||
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);
|
||||
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
|
||||
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
|
||||
await this._roles.save(); // Force a save to prevent data loss
|
||||
content = content.users;
|
||||
}
|
||||
|
||||
let duplicates = 0;
|
||||
for (let serializedUser of content.users) {
|
||||
let user = ControllerUser.fromJSON(serializedUser, this);
|
||||
for (const serializedUser of content) {
|
||||
const user = ControllerUser.fromJSON(serializedUser, this._roles);
|
||||
const existingUser = this.users.get(user.id);
|
||||
if (existingUser) {
|
||||
// Required migration to all lowercase ids in alpha 19
|
||||
@@ -65,30 +66,14 @@ export default class UserManager {
|
||||
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());
|
||||
}
|
||||
|
||||
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"));
|
||||
await lib.safeOutputFile(filePath,
|
||||
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,11 +88,11 @@ export default class UserManager {
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._config.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
|
||||
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
let user = new ControllerUser(this, 0, name, roles);
|
||||
let user = new ControllerUser(this._roles, 0, name, roles);
|
||||
this.users.set(user.id, user);
|
||||
this.dirty = true;
|
||||
return user;
|
||||
|
||||
@@ -239,6 +239,13 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
|
||||
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 {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -266,6 +270,12 @@ async function uploadExport(req: Request, res: Response) {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
type ProxyStreamEvents = {
|
||||
"error": [ err: any ],
|
||||
"close": [],
|
||||
"timeout": [],
|
||||
"source": [],
|
||||
}
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
@@ -274,7 +284,7 @@ export interface ProxyStream {
|
||||
size?: string;
|
||||
mime?: string;
|
||||
filename: string | null;
|
||||
events: events.EventEmitter;
|
||||
events: events.EventEmitter<ProxyStreamEvents>;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
@@ -658,6 +668,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)
|
||||
@@ -690,4 +701,5 @@ export const webRoutes = [
|
||||
"/roles/:id/view",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
"/error",
|
||||
];
|
||||
@@ -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: {
|
||||
|
||||
+36
-16
@@ -6,7 +6,6 @@ const fs = require("fs-extra");
|
||||
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");
|
||||
@@ -115,7 +114,7 @@ async function execFile(cmd, args) {
|
||||
escaped,
|
||||
{
|
||||
shell: true,
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
@@ -397,7 +396,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);
|
||||
}
|
||||
@@ -624,8 +623,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([
|
||||
@@ -689,6 +688,18 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
}
|
||||
|
||||
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"];
|
||||
@@ -741,10 +752,10 @@ 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 match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||
if (!match || !match.length) {
|
||||
@@ -761,17 +772,11 @@ async function downloadLinuxServer() {
|
||||
if (await fs.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.emptyDir(tmpDir);
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
res.pipe(writeStream);
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
@@ -786,8 +791,10 @@ async function downloadLinuxServer() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath);
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rm(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +820,10 @@ async function main() {
|
||||
.option("http-port", {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("download-headless", {
|
||||
describe: "Download the latest headless release [standalone/controller] [linux only]",
|
||||
type: "boolean", nargs: 0,
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
@@ -828,6 +839,9 @@ async function main() {
|
||||
.option("factorio-dir", {
|
||||
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
|
||||
})
|
||||
.option("remote-npm", {
|
||||
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
})
|
||||
@@ -893,6 +907,8 @@ async function main() {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
|
||||
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
@@ -912,6 +928,8 @@ async function main() {
|
||||
await execHost(["config", "set", "host.controller_token", hostToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
||||
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
@@ -923,6 +941,8 @@ 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)) {
|
||||
|
||||
@@ -50,7 +50,7 @@ function escapeArg(arg) {
|
||||
// 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
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.20",
|
||||
"version": "2.0.0-alpha.22b",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
@@ -13,13 +13,12 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"chalk": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"inquirer": "^8.2.4",
|
||||
"phin": "^3.7.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -3,6 +3,8 @@ const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const _package = require("./package.json");
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
|
||||
@@ -92,8 +94,8 @@ 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(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
@@ -102,6 +104,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"));
|
||||
}
|
||||
@@ -109,7 +112,7 @@ 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"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
@@ -139,7 +142,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// 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"));
|
||||
@@ -147,7 +149,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +165,25 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
config: config,
|
||||
// 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"),
|
||||
multi_context: pluginContexts > 1,
|
||||
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,3 +1,14 @@
|
||||
--[[
|
||||
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")
|
||||
|
||||
@@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat")
|
||||
local MyModule = {
|
||||
}
|
||||
|
||||
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
|
||||
-- The following helper function is desync safe and avoids name conflicts after being called during 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 globalData then make sure to call this function first
|
||||
--[[
|
||||
`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
|
||||
@@ -28,12 +41,13 @@ 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_init
|
||||
@@ -61,7 +75,7 @@ local function on_nth_tick_300()
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per module
|
||||
--- 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,
|
||||
@@ -73,7 +87,7 @@ 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
|
||||
--- 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,4 +1,8 @@
|
||||
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
|
||||
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
|
||||
-- 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")
|
||||
@@ -12,10 +12,22 @@
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "workspace:*"
|
||||
//%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
|
||||
@@ -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": "workspace:*",
|
||||
//%endif
|
||||
"@clusterio/lib": "workspace:*"
|
||||
//%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"
|
||||
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
dist/
|
||||
+1
-1
@@ -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,7 +1,7 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} = require("./messages");
|
||||
//%endif
|
||||
|
||||
@@ -26,7 +26,8 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%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
|
||||
}
|
||||
@@ -70,6 +71,7 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%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,
|
||||
);
|
||||
|
||||
@@ -7,12 +7,14 @@ 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
|
||||
@@ -21,6 +23,7 @@ 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
|
||||
@@ -29,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
|
||||
|
||||
@@ -40,7 +44,7 @@ const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
|
||||
@@ -24,18 +24,26 @@ class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
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") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages";
|
||||
//%endif
|
||||
|
||||
@@ -25,7 +25,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%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
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%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,
|
||||
);
|
||||
|
||||
@@ -6,12 +6,14 @@ 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
|
||||
@@ -20,6 +22,7 @@ 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
|
||||
@@ -28,6 +31,7 @@ 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
|
||||
|
||||
@@ -31,18 +31,25 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Called once rcon becomes available
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
this.logger.info("instance::onExit");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"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,8 +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/*"],
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
+91
-41
@@ -5,7 +5,7 @@
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
@@ -143,8 +143,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,84 +189,110 @@ 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();
|
||||
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
let 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;
|
||||
// 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 Plugins");
|
||||
let ctlPlugins = await loadPlugins(pluginList);
|
||||
// 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);
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
logger.verbose("Loading Plugins");
|
||||
ctlPlugins = await loadPlugins(pluginList);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -263,6 +311,7 @@ async function startControl() {
|
||||
tlsCa,
|
||||
controlConfig.get("control.controller_token")!,
|
||||
);
|
||||
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
@@ -278,12 +327,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
-10
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.20",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -17,25 +17,25 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"phin": "catalog:",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.3.0",
|
||||
"phin": "^3.7.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
+414
-82
@@ -95,6 +95,19 @@ controllerCommands.add(new lib.Command({
|
||||
await control.send(new lib.ControllerRestartRequest());
|
||||
},
|
||||
}));
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["update", "Update the controller", (yargs) => {
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { restart: boolean }, control: Control) {
|
||||
await control.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",
|
||||
@@ -184,28 +197,24 @@ controllerConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -234,6 +243,35 @@ controllerPluginCommands.add(new lib.Command({
|
||||
print(asTable(response.body));
|
||||
},
|
||||
}));
|
||||
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);
|
||||
|
||||
|
||||
@@ -322,6 +360,22 @@ hostCommands.add(new lib.Command({
|
||||
}
|
||||
},
|
||||
}));
|
||||
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",
|
||||
@@ -429,33 +483,29 @@ hostConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -464,9 +514,60 @@ hostConfigCommands.add(new lib.Command({
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
|
||||
const instanceCommands = new lib.CommandTree({
|
||||
name: "instance", alias: ["i"], description: "Instance management",
|
||||
});
|
||||
@@ -484,16 +585,21 @@ instanceCommands.add(new lib.Command({
|
||||
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 }, control: Control) {
|
||||
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);
|
||||
const serializedConfig = instanceConfig.toRemote("controller");
|
||||
await control.send(new lib.InstanceCreateRequest(serializedConfig));
|
||||
await control.send(new lib.InstanceCreateRequest(
|
||||
instanceConfig.toRemote("controller", [
|
||||
"instance.id", "instance.name",
|
||||
]),
|
||||
args.from
|
||||
));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -618,34 +724,30 @@ instanceConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -936,17 +1038,87 @@ 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 load, defaults to latest", nargs: 1, type: "string" },
|
||||
"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) {
|
||||
let instanceId = await lib.resolveInstance(control, args.instance);
|
||||
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) => {
|
||||
@@ -993,6 +1165,41 @@ instanceCommands.add(new lib.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
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" });
|
||||
@@ -1155,7 +1362,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
if (!version) {
|
||||
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
if (!lib.isFullVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
@@ -1208,8 +1415,8 @@ modPackCommands.add(new lib.Command({
|
||||
modPack.name = args.name;
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
@@ -1289,8 +1496,8 @@ modPackCommands.add(new lib.Command({
|
||||
if (args.name) { modPack.name = args.name; }
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
@@ -1344,6 +1551,9 @@ modCommands.add(new lib.Command({
|
||||
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) {
|
||||
@@ -1431,7 +1641,7 @@ modCommands.add(new lib.Command({
|
||||
) {
|
||||
let response = await control.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
args.factorioVersion,
|
||||
lib.normaliseApiVersion(args.factorioVersion as any),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
@@ -1510,6 +1720,9 @@ modCommands.add(new lib.Command({
|
||||
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")!);
|
||||
@@ -1550,6 +1763,9 @@ modCommands.add(new lib.Command({
|
||||
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));
|
||||
},
|
||||
}));
|
||||
@@ -1836,6 +2052,122 @@ userCommands.add(new lib.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
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.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
|
||||
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.writeJSON(args.filepath, await control.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), { spaces: 2 });
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
},
|
||||
}));
|
||||
|
||||
const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
|
||||
logCommands.add(new lib.Command({
|
||||
definition: ["follow", "follow cluster log", (yargs) => {
|
||||
|
||||
+28
-10
@@ -113,6 +113,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 +121,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,6 +149,9 @@ 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}`);
|
||||
@@ -160,29 +168,39 @@ 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();
|
||||
|
||||
// If we get here the command was run
|
||||
await fs.ensureDir(hostConfig.get("host.instances_directory"));
|
||||
await fs.ensureDir(hostConfig.get("host.mods_directory"));
|
||||
await fs.ensureDir("modules");
|
||||
@@ -221,11 +239,11 @@ async function startHost() {
|
||||
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
|
||||
host = new Host(
|
||||
hostConnector,
|
||||
args.config,
|
||||
hostConfig,
|
||||
tlsCa,
|
||||
pluginInfos,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Host.bootstrap(hostConfig)
|
||||
);
|
||||
|
||||
@@ -235,7 +253,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);
|
||||
}
|
||||
|
||||
@@ -248,7 +266,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ local has_quality = compat.version_ge("2.0.0")
|
||||
-- Equipment Grids are serialized into an array of equipment entries
|
||||
-- where ench entry is a table with the following fields:
|
||||
-- n: name
|
||||
-- g: ghost name (optional)
|
||||
-- t: to_be_removed (optional)
|
||||
-- p: position (array of 2 numbers corresponding to x and y)
|
||||
-- s: shield (optional)
|
||||
-- e: energy (optional)
|
||||
@@ -29,6 +31,8 @@ local has_quality = compat.version_ge("2.0.0")
|
||||
-- r: result inventory
|
||||
-- b: currently burning (optional)
|
||||
-- f: remaining_burning_fuel (optional)
|
||||
-- If the equipment has a quality the following is also present:
|
||||
-- q: quality (optional)
|
||||
function serialize.serialize_equipment_grid(grid)
|
||||
local serialized = {}
|
||||
local processed = {}
|
||||
@@ -44,6 +48,11 @@ function serialize.serialize_equipment_grid(grid)
|
||||
n = equipment.name,
|
||||
p = {pos.x, pos.y},
|
||||
}
|
||||
if equipment.type == "equipment-ghost" then
|
||||
if equipment.ghost_name then
|
||||
entry.g = equipment.ghost_name
|
||||
end
|
||||
end
|
||||
if equipment.shield > 0 then entry.s = equipment.shield end
|
||||
if equipment.energy > 0 then entry.e = equipment.energy end
|
||||
if equipment.burner then
|
||||
@@ -55,6 +64,12 @@ function serialize.serialize_equipment_grid(grid)
|
||||
entry.f = burner.remaining_burning_fuel
|
||||
end
|
||||
end
|
||||
if equipment.quality then
|
||||
entry.q = equipment.quality.name
|
||||
end
|
||||
if equipment.to_be_removed then
|
||||
entry.t = equipment.to_be_removed
|
||||
end
|
||||
table.insert(serialized, entry)
|
||||
end
|
||||
end
|
||||
@@ -67,8 +82,10 @@ function serialize.deserialize_equipment_grid(grid, serialized)
|
||||
grid.clear()
|
||||
for _, entry in ipairs(serialized) do
|
||||
local equipment = grid.put({
|
||||
name = entry.n,
|
||||
name = entry.g or entry.n,
|
||||
quality = has_quality and entry.q or nil,
|
||||
position = entry.p,
|
||||
ghost = entry.g and true or nil,
|
||||
})
|
||||
if equipment then
|
||||
if entry.s then equipment.shield = entry.s end
|
||||
|
||||
+14
-13
@@ -3,10 +3,11 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.20",
|
||||
"version": "2.0.0-alpha.22b",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -17,33 +18,33 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"phin": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"jimp": "^0.22.8",
|
||||
"jszip": "^3.10.1",
|
||||
"phin": "^3.7.0",
|
||||
"pidusage": "^3.0.2",
|
||||
"rcon-client": "^4.2.5",
|
||||
"semver": "^7.7.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/node": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/pidusage": "^2.0.2",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
+116
-34
@@ -43,19 +43,20 @@ async function discoverInstances(instancesDir: string) {
|
||||
let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
let instanceConfig = new lib.InstanceConfig("host");
|
||||
let configPath = path.join(instancesDir, entry.name, "instance.json");
|
||||
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
|
||||
const instancePath = path.join(instancesDir, entry.name);
|
||||
const configPath = path.join(instancePath, "instance.json");
|
||||
|
||||
try {
|
||||
const jsonConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
instanceConfig = lib.InstanceConfig.fromJSON(jsonConfig, "host");
|
||||
instanceConfig = await lib.InstanceConfig.fromFile("host", configPath);
|
||||
instanceConfig.update(Host.instanceConfigWarning, false); // File might be missing the warning
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
continue; // Ignore folders without config.json
|
||||
}
|
||||
|
||||
logger.error(`Error occured while parsing ${configPath}: ${err.message}`);
|
||||
logger.error(`Error occurred while parsing ${configPath}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -64,7 +65,6 @@ async function discoverInstances(instancesDir: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let instancePath = path.join(instancesDir, entry.name);
|
||||
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
|
||||
instanceInfos.set(instanceConfig.get("instance.id"), {
|
||||
path: instancePath,
|
||||
@@ -250,7 +250,7 @@ export class HostRouter {
|
||||
* Connects to the controller over the WebSocket and manages intsances.
|
||||
*/
|
||||
export default class Host extends lib.Link {
|
||||
declare ["connector"]: HostConnector;
|
||||
declare "connector": HostConnector;
|
||||
|
||||
router = new HostRouter(this);
|
||||
/**
|
||||
@@ -258,7 +258,6 @@ export default class Host extends lib.Link {
|
||||
*/
|
||||
tlsCa?: string;
|
||||
pluginInfos: lib.PluginNodeEnvInfo[];
|
||||
configPath: string;
|
||||
config: lib.HostConfig;
|
||||
|
||||
/** Mapping of plugin name to loaded plugin */
|
||||
@@ -279,6 +278,10 @@ export default class Host extends lib.Link {
|
||||
_disconnecting = false;
|
||||
_shuttingDown = false;
|
||||
|
||||
static instanceConfigWarning = {
|
||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||
};
|
||||
|
||||
static async bootstrap(hostConfig: lib.HostConfig) {
|
||||
const modsDirectory = hostConfig.get("host.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
@@ -289,7 +292,6 @@ export default class Host extends lib.Link {
|
||||
|
||||
constructor(
|
||||
connector: HostConnector,
|
||||
hostConfigPath: string,
|
||||
hostConfig: lib.HostConfig,
|
||||
tlsCa: string | undefined,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
@@ -298,13 +300,17 @@ export default class Host extends lib.Link {
|
||||
* will restart this host on a non-zero exit codes.
|
||||
*/
|
||||
public canRestart = false,
|
||||
/**
|
||||
* If true indicates the host is in recovery mode and should
|
||||
* disable certain actions such as loading plugins or instance autostart
|
||||
*/
|
||||
public recoveryMode = false,
|
||||
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
|
||||
) {
|
||||
super(connector);
|
||||
this.tlsCa = tlsCa;
|
||||
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.configPath = hostConfigPath;
|
||||
this.config = hostConfig;
|
||||
|
||||
this.config.on("fieldChanged", (name, curr, prev) => {
|
||||
@@ -371,17 +377,23 @@ export default class Host extends lib.Link {
|
||||
|
||||
this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this));
|
||||
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
|
||||
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
|
||||
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
|
||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.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.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this));
|
||||
|
||||
this.handle(lib.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this));
|
||||
this.handle(lib.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this));
|
||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
this.fallbackRequest(
|
||||
lib.InstanceSaveDetailsListRequest, this.fallbackInstanceSaveDetailsListRequest.bind(this),
|
||||
);
|
||||
@@ -398,11 +410,16 @@ export default class Host extends lib.Link {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.recoveryMode) {
|
||||
logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let HostPluginClass = BaseHostPlugin;
|
||||
try {
|
||||
if (pluginInfo.hostEntrypoint) {
|
||||
@@ -645,10 +662,11 @@ export default class Host extends lib.Link {
|
||||
instanceInfo.config.update(config, true, "controller");
|
||||
|
||||
} else {
|
||||
let instanceConfig = new lib.InstanceConfig("host");
|
||||
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
|
||||
instanceConfig.update(config, false, "controller");
|
||||
|
||||
let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name"));
|
||||
instanceConfig.filepath = path.join(instanceDir, "instance.json");
|
||||
|
||||
logger.info(`Creating ${instanceDir}`);
|
||||
await Instance.populate_folders(instanceDir);
|
||||
@@ -675,7 +693,8 @@ export default class Host extends lib.Link {
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
: instanceInfo.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -683,18 +702,7 @@ export default class Host extends lib.Link {
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
|
||||
// save a copy of the instance config
|
||||
await this.saveInstanceConfig(instanceInfo);
|
||||
}
|
||||
|
||||
async saveInstanceConfig(instanceInfo: { path: string, config: lib.InstanceConfig }) {
|
||||
let warnedOutput = {
|
||||
_warning: "Changes to this file will be overwritten by the controller's copy.",
|
||||
...instanceInfo.config.toJSON(),
|
||||
};
|
||||
await lib.safeOutputFile(
|
||||
path.join(instanceInfo.path, "instance.json"),
|
||||
JSON.stringify(warnedOutput, null, "\t")
|
||||
);
|
||||
await instanceInfo.config.save();
|
||||
}
|
||||
|
||||
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
|
||||
@@ -798,7 +806,48 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
async handleSystemInfoRequest() {
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart);
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let packageName = "@clusterio/host";
|
||||
try {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("host.version");
|
||||
const packageJsonPath = require.resolve("@clusterio/host/package.json");
|
||||
const packageJson = await fs.readJSON(packageJsonPath);
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second check plugin versions
|
||||
for (const pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
|
||||
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async handleHostMetricsRequest() {
|
||||
@@ -1043,6 +1092,35 @@ export default class Host extends lib.Link {
|
||||
await fs.remove(instanceInfo.path);
|
||||
}
|
||||
|
||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||
if (!this.config.get("host.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/host");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
if (!this.config.get("host.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||
if (!this.config.get("host.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginInstall(request.pluginPackage);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this.plugins.has(pluginInfo.name),
|
||||
this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
}
|
||||
|
||||
sendHostUpdate() {
|
||||
this.send(
|
||||
new lib.HostInfoUpdateEvent(
|
||||
@@ -1080,6 +1158,10 @@ export default class Host extends lib.Link {
|
||||
|
||||
for (let [instanceId, instanceInfo] of this.instanceInfos) {
|
||||
if (instanceInfo.config.get("instance.auto_start")) {
|
||||
if (this.recoveryMode) {
|
||||
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let instanceConnection = await this._connectInstance(instanceId);
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
||||
@@ -1131,11 +1213,11 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
logger.info("Saving config");
|
||||
await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t"));
|
||||
for (const instanceInfo of this.instanceInfos.values()) {
|
||||
// Save in case host_assigned_game_port changed in the config.
|
||||
await this.saveInstanceConfig(instanceInfo);
|
||||
}
|
||||
await this.config.save();
|
||||
// Save host side in case host_assigned_game_port changed in the config.
|
||||
await Promise.all(
|
||||
[...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save())
|
||||
);
|
||||
|
||||
try {
|
||||
// Clear silly interval in pidfile library.
|
||||
@@ -1149,7 +1231,7 @@ export default class Host extends lib.Link {
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,8 @@ export default class Instance extends lib.Link {
|
||||
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
version: this.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
version: this.config.get("factorio.version") as lib.TargetVersion,
|
||||
executablePath: this.config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
|
||||
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
|
||||
@@ -218,6 +219,7 @@ export default class Instance extends lib.Link {
|
||||
enableWhitelist: this.config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: this.config.get("factorio.verbose_logging"),
|
||||
consoleLogging: this.config.get("factorio.console_logging"),
|
||||
stripPaths: this.config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
|
||||
shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000,
|
||||
@@ -265,6 +267,15 @@ export default class Instance extends lib.Link {
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on("whitelist-change", (added: string[], removed: string[]) => {
|
||||
for (const player of added) {
|
||||
this._recordUserUpdate("WHITELISTED", player);
|
||||
}
|
||||
for (const player of removed) {
|
||||
this._recordUserUpdate("UNWHITELISTED", player);
|
||||
}
|
||||
});
|
||||
|
||||
this.handle(lib.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this));
|
||||
this.handle(lib.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this));
|
||||
this.handle(lib.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this));
|
||||
@@ -279,6 +290,7 @@ export default class Instance extends lib.Link {
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
|
||||
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
|
||||
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
|
||||
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
|
||||
this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this));
|
||||
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
|
||||
@@ -542,7 +554,8 @@ end`.replace(/\r?\n/g, " ");
|
||||
this.id,
|
||||
status,
|
||||
this.server.gamePort,
|
||||
status === "running"? this.server.version : this.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -640,11 +653,16 @@ end`.replace(/\r?\n/g, " ");
|
||||
if (
|
||||
!pluginInfo.instanceEntrypoint
|
||||
|| !this._host.serverPlugins.has(pluginInfo.name)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this._host.recoveryMode) {
|
||||
this.logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._loadPlugin(pluginInfo, this._host);
|
||||
} catch (err) {
|
||||
@@ -944,10 +962,15 @@ end`.replace(/\r?\n/g, " ");
|
||||
this._loadedSave = saveName;
|
||||
await this.server.start(saveName);
|
||||
|
||||
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
|
||||
if (this.config.get("factorio.enable_script_commands")) {
|
||||
await this.server.disableAchievements();
|
||||
await this.updateInstanceData();
|
||||
}
|
||||
|
||||
if (this.config.get("factorio.enable_save_patching")) {
|
||||
this._watchPlayerPromote();
|
||||
if (this.config.get("factorio.enable_script_commands")) {
|
||||
await this.updateInstanceData();
|
||||
}
|
||||
} else {
|
||||
this._watchServerLogActions();
|
||||
}
|
||||
@@ -1030,13 +1053,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
name: string,
|
||||
reason: string = ""
|
||||
) {
|
||||
// TODO: Implement bidirectional whitelist sync, likely by watching the json file
|
||||
const addr = lib.Address.fromShorthand("allInstances");
|
||||
const expectedIndex = this._expectedUserUpdates.findIndex(
|
||||
expected => expected.name === name && expected.action === action && expected.reason === reason
|
||||
);
|
||||
if (expectedIndex >= 0) {
|
||||
this._expectedUserUpdates = this._expectedUserUpdates.splice(expectedIndex, 1);
|
||||
this._expectedUserUpdates.splice(expectedIndex, 1);
|
||||
} else if (action === "BAN") {
|
||||
if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; }
|
||||
this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, true, reason ?? ""));
|
||||
@@ -1286,6 +1308,14 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this.stop();
|
||||
// Stopping invalidates this instance object, a new one must be created by the host
|
||||
// To do this we simulate a waking an instance and sending a start request
|
||||
const instanceConnection = await this._host._connectInstance(this.id);
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ async function loadLayeredIcon(
|
||||
iconLayer.scale(realScale);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
|
||||
x += xs;
|
||||
y += ys;
|
||||
|
||||
+131
-14
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
||||
* @returns Array with path to data dir and version found.
|
||||
* @internal
|
||||
*/
|
||||
async function findVersion(factorioDir: string, targetVersion: string) {
|
||||
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
|
||||
|
||||
// There are two supported setups: having the factorio dir be the actual
|
||||
// install directory, and having the factorio dir be a folder containing
|
||||
@@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: string) {
|
||||
|
||||
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
|
||||
if (simpleVersion !== null) {
|
||||
if (simpleVersion === targetVersion || targetVersion === "latest") {
|
||||
return [path.join(factorioDir, "data"), simpleVersion];
|
||||
if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") {
|
||||
if (lib.isFullVersion(simpleVersion)) {
|
||||
return [path.join(factorioDir, "data"), simpleVersion];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
@@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: string) {
|
||||
);
|
||||
}
|
||||
|
||||
let versions = new Map();
|
||||
let versions = new Map<lib.FullVersion, string>();
|
||||
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
|
||||
if (version === null) {
|
||||
if (version === null || !lib.isFullVersion(version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: string) {
|
||||
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
|
||||
}
|
||||
|
||||
if (lib.isPartialVersion(targetVersion)) {
|
||||
const sorted = [...versions.keys()].sort(versionOrder);
|
||||
const latest = sorted.find(version => version.startsWith(targetVersion));
|
||||
if (latest) {
|
||||
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
|
||||
}
|
||||
}
|
||||
|
||||
if (targetVersion === "latest") {
|
||||
let latest = [...versions.keys()].sort(versionOrder)[0];
|
||||
return [path.join(factorioDir, versions.get(latest), "data"), latest];
|
||||
const latest = [...versions.keys()].sort(versionOrder)[0];
|
||||
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
|
||||
}
|
||||
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
@@ -423,7 +433,7 @@ export interface FactorioServerOptions {
|
||||
* Version of Factorio to use. Can also be the string "latest" to use
|
||||
* the latest version found in `factorioDir`.
|
||||
*/
|
||||
version?: string,
|
||||
version?: lib.TargetVersion,
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port to host game on. */
|
||||
@@ -438,6 +448,8 @@ export interface FactorioServerOptions {
|
||||
enableAuthserverBans?: boolean,
|
||||
/** Enable verbose logging. */
|
||||
verboseLogging?: boolean,
|
||||
/** Enable console logging. */
|
||||
consoleLogging?: boolean,
|
||||
/** Strip paths in the console. */
|
||||
stripPaths?: boolean,
|
||||
/**
|
||||
@@ -452,6 +464,32 @@ export interface FactorioServerOptions {
|
||||
shutdownTimeoutMs?: number,
|
||||
}
|
||||
|
||||
type FactorioServerEvents = {
|
||||
// Public Events
|
||||
"stdout": [ rawLine: Buffer ],
|
||||
"stderr": [ rawLine: Buffer ],
|
||||
"output": [ parsed: lib.ParsedFactorioOutput, line: string ]
|
||||
|
||||
"error": [ err: any ],
|
||||
"rcon-ready": [],
|
||||
"game-ready": [],
|
||||
"exit": [],
|
||||
|
||||
"autosave-start": [ name: string ],
|
||||
"autosave-finished": [ name: string ],
|
||||
"save-finished": [],
|
||||
|
||||
"whitelist-change": [ added: string[], removed: string[] ],
|
||||
|
||||
// Private Events
|
||||
"_autosave": [ name: string ]
|
||||
"_saving": [],
|
||||
"_saved": [],
|
||||
|
||||
// IPS events
|
||||
[ipcEvent: `ipc-${string}`]: [ event: any ],
|
||||
};
|
||||
|
||||
/**
|
||||
* Factorio Server interface
|
||||
*
|
||||
@@ -471,7 +509,7 @@ export interface FactorioServerOptions {
|
||||
* - exit - invoked when the sterver has exited
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class FactorioServer extends events.EventEmitter {
|
||||
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
@@ -486,6 +524,8 @@ export class FactorioServer extends events.EventEmitter {
|
||||
enableAuthserverBans: boolean;
|
||||
/** Enable verbose logging */
|
||||
verboseLogging: boolean;
|
||||
/** Enable console logging */
|
||||
consoleLogging: boolean;
|
||||
_shutdownTimeoutMs = 0;
|
||||
_stopTimeoutId?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -493,12 +533,16 @@ export class FactorioServer extends events.EventEmitter {
|
||||
_writeDir: string;
|
||||
|
||||
// Resolved in init
|
||||
_version: string | null = null;
|
||||
_version: lib.FullVersion | null = null;
|
||||
_dataDir: string | null = null;
|
||||
|
||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||
// https://forums.factorio.com/viewtopic.php?t=123673
|
||||
_whitelistWatcher: fs.FSWatcher | null = null;
|
||||
_whitelist = new Set<string>();
|
||||
|
||||
_logger: lib.Logger;
|
||||
_targetVersion: string;
|
||||
_targetVersion: lib.TargetVersion;
|
||||
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
|
||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||
_rconClient: Rcon | null= null;
|
||||
@@ -541,6 +585,8 @@ export class FactorioServer extends events.EventEmitter {
|
||||
this.enableAuthserverBans = options.enableAuthserverBans || false;
|
||||
/** Enable verbose logging */
|
||||
this.verboseLogging = options.verboseLogging || false;
|
||||
/** Enable console logging */
|
||||
this.consoleLogging = options.consoleLogging || false;
|
||||
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
|
||||
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
|
||||
if (options.shutdownTimeoutMs !== undefined) {
|
||||
@@ -711,7 +757,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
this._rconClient.on("authenticated", () => {
|
||||
this._rconReady = true;
|
||||
this.emit("rcon-ready");
|
||||
});
|
||||
});
|
||||
this._rconClient.on("end", () => {
|
||||
this._rconClient = null;
|
||||
if (!this._rconReady) {
|
||||
@@ -735,6 +781,62 @@ export class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
_watchWhitelist() {
|
||||
if (this._whitelistWatcher !== null) {
|
||||
throw new Error("Whitelist watcher already started");
|
||||
}
|
||||
|
||||
if (!this.enableWhitelist) {
|
||||
return; // The file is not updated if the whitelist is disabled
|
||||
}
|
||||
|
||||
const filePath = this.writePath("server-whitelist.json");
|
||||
try {
|
||||
this._whitelistWatcher = fs.watch(filePath);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._whitelistWatcher.unref();
|
||||
this._whitelistWatcher.on("error", (err) => {
|
||||
this._whitelistWatcher = null;
|
||||
this._logger.error(`Whitelist Watcher has errored:\n${err?.stack ?? err?.message ?? err}`);
|
||||
});
|
||||
this._whitelistWatcher.on("close", () => {
|
||||
this._whitelistWatcher = null;
|
||||
});
|
||||
this._whitelistWatcher.on("change", async (eventType) => {
|
||||
if (eventType !== "change") {
|
||||
this._logger.warn(`Unexpected file watcher event: ${eventType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let newWhitelistJson;
|
||||
try {
|
||||
newWhitelistJson = await fs.readJSON(filePath);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(newWhitelistJson instanceof Array) || !newWhitelistJson.every(e => typeof e === "string")) {
|
||||
// The whitelist must be an array of strings
|
||||
this._logger.error("Unable to read whitelist, bidirectional sync will not be available:" +
|
||||
"Unexpected whitelist format");
|
||||
return;
|
||||
}
|
||||
|
||||
const newWhitelist = new Set(newWhitelistJson);
|
||||
// Set.difference added in node v22 and so we can not use this due to v20 support
|
||||
const added = newWhitelistJson.filter(e => !this._whitelist.has(e));
|
||||
const removed = [...this._whitelist.values()].filter(e => !newWhitelist.has(e));
|
||||
this._whitelist = newWhitelist;
|
||||
|
||||
this.emit("whitelist-change", added, removed);
|
||||
});
|
||||
}
|
||||
|
||||
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
|
||||
get maxConcurrentCommands() {
|
||||
return this._maxConcurrentCommands;
|
||||
@@ -774,7 +876,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
* "latest" (the default) was specified as `factorioVersion` to the constructor.
|
||||
* This will be undefined before the server is initialized, or if init failed.
|
||||
*/
|
||||
get version(): string | undefined {
|
||||
get version(): lib.FullVersion | undefined {
|
||||
return this._version ?? undefined;
|
||||
}
|
||||
|
||||
@@ -848,6 +950,10 @@ export class FactorioServer extends events.EventEmitter {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
}
|
||||
|
||||
this._resetState();
|
||||
this.emit("exit");
|
||||
});
|
||||
@@ -862,6 +968,10 @@ export class FactorioServer extends events.EventEmitter {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
}
|
||||
|
||||
this._resetState();
|
||||
this.emit("exit");
|
||||
});
|
||||
@@ -973,12 +1083,14 @@ export class FactorioServer extends events.EventEmitter {
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "pipe",
|
||||
}
|
||||
);
|
||||
this._watchWhitelist();
|
||||
this._attachStdio();
|
||||
this._watchExit();
|
||||
/* eslint-enable node/no-sync */
|
||||
@@ -1029,12 +1141,14 @@ export class FactorioServer extends events.EventEmitter {
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "pipe",
|
||||
}
|
||||
);
|
||||
this._watchWhitelist();
|
||||
this._attachStdio();
|
||||
this._watchExit();
|
||||
/* eslint-enable node/no-sync */
|
||||
@@ -1110,6 +1224,9 @@ export class FactorioServer extends events.EventEmitter {
|
||||
if (this._rconClient) {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
}
|
||||
this._server!.kill("SIGKILL");
|
||||
}
|
||||
|
||||
@@ -1131,7 +1248,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
// complete before the RCON connection can be used
|
||||
if (!this._rconReady) {
|
||||
// Not using events.once here to avoid throwing on error events.
|
||||
await new Promise(resolve => this.once("rcon-ready", resolve));
|
||||
await new Promise<void>(resolve => this.once("rcon-ready", resolve));
|
||||
}
|
||||
|
||||
// The Factorio server may have decided to get ahead of us and
|
||||
|
||||
@@ -15,4 +15,4 @@ export * from "./src/subscriptions";
|
||||
|
||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
|
||||
export { default as ValueCache } from "./src/ValueCache";
|
||||
Vendored
+7
-7
@@ -1,10 +1,10 @@
|
||||
export interface BuildArgs {
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
};
|
||||
export function build(args: BuildArgs): Promise<void>;
|
||||
@@ -115,9 +115,21 @@ async function main() {
|
||||
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
|
||||
"build": { describe: "Build mod(s)", type: "boolean", default: true },
|
||||
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
|
||||
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
|
||||
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
|
||||
"factorio-version": { describe: "Override factorio_version", type: "string" },
|
||||
"source-dir": {
|
||||
describe: "Path to mod source files",
|
||||
type: "string",
|
||||
nargs: 1,
|
||||
normalize: true,
|
||||
demandOption: true,
|
||||
},
|
||||
"output-dir": {
|
||||
describe: "Path to output built mod(s)",
|
||||
type: "string",
|
||||
nargs: 1,
|
||||
normalize: true,
|
||||
default: "dist/factorio",
|
||||
},
|
||||
})
|
||||
.strict()
|
||||
.argv
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./src/config";
|
||||
export * from "./src/data";
|
||||
export * from "./src/database";
|
||||
export * from "./src/errors";
|
||||
export * from "./src/external";
|
||||
export * from "./src/factorio";
|
||||
export * from "./src/file_ops";
|
||||
export * from "./src/hash";
|
||||
@@ -30,8 +31,10 @@ export * from "./src/zip_ops";
|
||||
export * from "./src/subscriptions";
|
||||
export * from "./src/datastore";
|
||||
export * from "./src/load_plugin_list";
|
||||
export * from "./src/rce_ops";
|
||||
export * from "./src/LockFile";
|
||||
|
||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as ModStore } from "./src/ModStore";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
|
||||
export { default as ValueCache } from "./src/ValueCache";
|
||||
+16
-16
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.20",
|
||||
"version": "2.0.0-alpha.22b",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -18,30 +18,30 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"ajv": "^8.17.1",
|
||||
"chalk": "^4.1.2",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-transport": "catalog:",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jszip": "^3.10.1",
|
||||
"klaw": "^4.1.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"triple-beam": "^1.4.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-transport": "^4.9.0",
|
||||
"ws": "^8.18.1",
|
||||
"ajv": "^8.17.1",
|
||||
"klaw": "^4.1.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { FileHandle, open, unlink, readFile } from "fs/promises";
|
||||
import { unlinkSync } from "fs";
|
||||
import assert from "assert";
|
||||
|
||||
/**
|
||||
* Error thrown when the lock file already exists.
|
||||
*/
|
||||
export class LockFileExistsError extends Error {
|
||||
constructor(
|
||||
readonly filePath: string
|
||||
) {
|
||||
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
|
||||
this.name = "LockFileExistsError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the acquire or release called during an invalid state.
|
||||
*/
|
||||
export class LockFileBusyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "LockFileBusyError";
|
||||
}
|
||||
}
|
||||
|
||||
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
||||
|
||||
/**
|
||||
* Cross-platform lock file utility for Node.js.
|
||||
*/
|
||||
export class LockFile {
|
||||
private handle: FileHandle | null = null;
|
||||
private state: LockState = "idle";
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Acquire the lock file.
|
||||
* Throws LockFileExistsError if already locked.
|
||||
* Throws LockFileBusyError if not in idle state.
|
||||
*/
|
||||
async acquire(): Promise<void> {
|
||||
if (this.state !== "idle") {
|
||||
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
|
||||
}
|
||||
|
||||
this.state = "acquiring";
|
||||
|
||||
if (await this.isStale()) {
|
||||
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
|
||||
}
|
||||
|
||||
try {
|
||||
this.handle = await open(this.filePath, "wx");
|
||||
await this.handle.write(`${process.pid}\n`);
|
||||
this.attachExitHandlers();
|
||||
this.state = "acquired";
|
||||
} catch (err: any) {
|
||||
this.state = "idle";
|
||||
if (err?.code === "EEXIST") {
|
||||
throw new LockFileExistsError(this.filePath);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock file.
|
||||
* Throws LockFileBusyError if not in acquired state.
|
||||
*/
|
||||
async release(): Promise<void> {
|
||||
if (this.state !== "acquired") {
|
||||
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
|
||||
}
|
||||
|
||||
this.state = "releasing";
|
||||
this.detachExitHandlers();
|
||||
|
||||
// Ignored: file may have already been removed by another process
|
||||
try { await this.handle?.close(); } catch {}
|
||||
try { await unlink(this.filePath); } catch {}
|
||||
|
||||
this.handle = null;
|
||||
this.state = "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the lock is currently held by this process.
|
||||
* True only if the lock is acquired and the PID in the file
|
||||
* matches the current process.
|
||||
*/
|
||||
async isHeld(): Promise<boolean> {
|
||||
if (this.state !== "acquired") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
return pidInLock === process.pid;
|
||||
} catch {
|
||||
// Ignored: file may not exist or be unreadable, so lock is not held
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an existing lock is stale.
|
||||
*/
|
||||
async isStale(): Promise<boolean> {
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
if (!Number.isFinite(pidInLock)) { return true; }
|
||||
// signal 0 is a special case to test if a process exists
|
||||
process.kill(pidInLock, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach process handlers, called when transitioning to "acquired"
|
||||
*/
|
||||
private attachExitHandlers(): void {
|
||||
assert(this.state === "acquiring");
|
||||
process.on("SIGINT", this.handleExit);
|
||||
process.on("SIGTERM", this.handleExit);
|
||||
process.on("SIGHUP", this.handleExit);
|
||||
process.on("uncaughtException", this.handleExit);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
process.on("exit", this.handleExitSync);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach process handlers, called when transitioning to "idle"
|
||||
*/
|
||||
private async detachExitHandlers(): Promise<void> {
|
||||
assert(this.state === "releasing");
|
||||
process.off("SIGINT", this.handleExit);
|
||||
process.off("SIGTERM", this.handleExit);
|
||||
process.off("SIGHUP", this.handleExit);
|
||||
process.off("uncaughtException", this.handleExit);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
process.off("exit", this.handleExitSync);
|
||||
}
|
||||
|
||||
// Event handler for async signals / exceptions
|
||||
private handleExit = async (): Promise<void> => {
|
||||
await this.release();
|
||||
};
|
||||
|
||||
// Event handler for synchronous process exit
|
||||
private handleExitSync = (): void => {
|
||||
try {
|
||||
unlinkSync(this.filePath);
|
||||
} catch (err) {
|
||||
// Ignored: file may have already been removed by another process
|
||||
}
|
||||
};
|
||||
}
|
||||
+250
-14
@@ -1,17 +1,43 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import events from "events";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging";
|
||||
import { ModInfo } from "./data";
|
||||
import TypedEventEmitter from "./TypedEventEmitter";
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
import { Writable } from "stream";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
|
||||
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
change: (mod: ModInfo) => void;
|
||||
"change": [ mod: ModInfo ];
|
||||
}
|
||||
|
||||
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
|
||||
interface ModsInfoResponse {
|
||||
pagination: any,
|
||||
results: Array<Static<typeof ModPortalDetailsSchema>>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing the structure of the pagination info from the portal API
|
||||
*/
|
||||
interface ModPortalPagination {
|
||||
count: number;
|
||||
links: { next?: string; prev?: string; };
|
||||
page: number;
|
||||
page_count: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing the structure of the portal API response for a single page
|
||||
*/
|
||||
interface ModPortalResponse {
|
||||
pagination: ModPortalPagination;
|
||||
results: Static<typeof ModPortalDetailsSchema>[];
|
||||
}
|
||||
|
||||
export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
constructor(
|
||||
public modsDirectory: string,
|
||||
public files: Map<string, ModInfo>,
|
||||
@@ -19,7 +45,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
super();
|
||||
}
|
||||
|
||||
getMod(name: string, version: string, sha1?: string) {
|
||||
getMod(name: string, version: FullVersion, sha1?: string) {
|
||||
const mod = this.files.get(ModInfo.filename(name, version));
|
||||
if (!mod || sha1 && sha1 !== mod.sha1) {
|
||||
return undefined;
|
||||
@@ -27,11 +53,6 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
return mod;
|
||||
}
|
||||
|
||||
hasMod(name: string, version: string, sha1?: string): boolean {
|
||||
const mod = this.files.get(ModInfo.filename(name, version));
|
||||
return Boolean(mod && (!sha1 || sha1 !== mod.sha1));
|
||||
}
|
||||
|
||||
mods() { return this.files.values(); }
|
||||
|
||||
static async loadFile(modsDirectory: string, file: string) {
|
||||
@@ -44,8 +65,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
|
||||
async loadFile(file: string) {
|
||||
let modInfo = await ModStore.loadFile(this.modsDirectory, file);
|
||||
this.files.set(modInfo.filename, modInfo);
|
||||
this.emit("change", modInfo);
|
||||
this.addMod(modInfo);
|
||||
return modInfo;
|
||||
}
|
||||
|
||||
@@ -67,11 +87,11 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
async loadMod(name: string, version: string) {
|
||||
async loadMod(name: string, version: FullVersion) {
|
||||
return await this.loadFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
async deleteMod(name: string, version: string) {
|
||||
async deleteMod(name: string, version: FullVersion) {
|
||||
return await this.deleteFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
@@ -134,4 +154,220 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
|
||||
return new this(modsDirectory, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a url's content into a file.
|
||||
*
|
||||
* @param url - Web path to stream from.
|
||||
* @param filePath - File path to stream to.
|
||||
*/
|
||||
private static async downloadFile(url: string | URL, filePath: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing.");
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(filePath);
|
||||
const writer = Writable.toWeb(fileStream);
|
||||
await response.body.pipeTo(writer);
|
||||
} catch (error) {
|
||||
logger.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a matching version from a selection of available versions.
|
||||
*
|
||||
* @param releases - Array of mod releases taken from the mod portal.
|
||||
* @param version - The required version to install.
|
||||
* @param username - Factorio username to download using.
|
||||
* @param token - Factorio token to download using.
|
||||
*/
|
||||
private async downloadModFromReleases(
|
||||
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
|
||||
username: string, token: string
|
||||
) {
|
||||
const selectedRelease = releases.find(release => version.testVersion(release.version));
|
||||
|
||||
if (selectedRelease === undefined) {
|
||||
logger.warn(`Mod release matching version ${version} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`);
|
||||
url.searchParams.set("username", username);
|
||||
url.searchParams.set("token", token);
|
||||
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
|
||||
const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`;
|
||||
|
||||
await ModStore.downloadFile(url, tempFileName);
|
||||
await fs.rename(tempFileName, finalFileName);
|
||||
await this.loadFile(selectedRelease.file_name);
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`);
|
||||
// Attempt to clean up temp file if it exists
|
||||
try {
|
||||
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
|
||||
await fs.unlink(tempFileName);
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist
|
||||
logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads all provided mods, does not perform chunking or filtering of builtins
|
||||
*
|
||||
* @param mods - An array of mods to download
|
||||
* @param username - Factorio username used for auth to download mods
|
||||
* @param token - Factorio token used for auth to download mods
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
private async downloadModsChunk(
|
||||
mods: ModNameVersionPair[],
|
||||
username: string, token: string,
|
||||
factorioVersion: ApiVersion,
|
||||
) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", "max");
|
||||
url.searchParams.set("version", factorioVersion);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const modReleases = (await response.json() as ModsInfoResponse).results;
|
||||
await Promise.all(modReleases.map((mod) => {
|
||||
const modVersion = mods.find(m => m.name === mod.name)!.version;
|
||||
if (mod.releases) {
|
||||
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
|
||||
} else if (mod.latest_release) {
|
||||
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
|
||||
}
|
||||
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads all provided mods to the mod dir.
|
||||
*
|
||||
* Handles chunking to prevent errors due to URL length.
|
||||
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
|
||||
*
|
||||
* @param mods - An array of mods to download.
|
||||
* @param username - Factorio username used for auth to download mods.
|
||||
* @param token - Factorio token used for auth to download mods.
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
async downloadMods(
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
|
||||
) {
|
||||
const chunkSize = 500;
|
||||
const futures: Promise<void>[] = [];
|
||||
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
|
||||
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
|
||||
|
||||
for (let i = 0; i < mods.length; i += chunkSize) {
|
||||
futures.push(
|
||||
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(futures);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an array of mods based on if they are installed.
|
||||
*
|
||||
* @param mods - Array of mods to filer.
|
||||
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
|
||||
* @returns Filtered array of mods based on install state.
|
||||
*/
|
||||
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
|
||||
const installedMods = Array.from(this.mods());
|
||||
return mods.filter(mod => (
|
||||
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all mods from the Factorio Mod Portal for a specific Factorio version.
|
||||
*
|
||||
* Handles pagination automatically to retrieve the complete list.
|
||||
*
|
||||
* @param factorioVersion - The Factorio version to fetch mods for (e.g., "1.1").
|
||||
* @param pageSize - The page size for fetching mods.
|
||||
* @param hide_deprecated - Whether to exclude deprecated mods.
|
||||
* @returns An array of mod details.
|
||||
*/
|
||||
static async fetchAllModsFromPortal(
|
||||
factorioVersion: string,
|
||||
pageSize: number,
|
||||
hide_deprecated: boolean = false
|
||||
): Promise<Static<typeof ModPortalDetailsSchema>[]> {
|
||||
logger.info(`Fetching all mods for Factorio version ${factorioVersion}...`);
|
||||
let currentPage = 1;
|
||||
let allMods: Static<typeof ModPortalDetailsSchema>[] = [];
|
||||
let hasMorePages = true;
|
||||
|
||||
while (hasMorePages) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", String(pageSize));
|
||||
url.searchParams.set("version", factorioVersion);
|
||||
url.searchParams.set("page", String(currentPage));
|
||||
url.searchParams.set("hide_deprecated", String(hide_deprecated));
|
||||
|
||||
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
|
||||
errorMessage += `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
errorMessage += ` - ${await response.text()}`;
|
||||
} catch (bodyError) {
|
||||
logger.warn("Failed to read response body for failed portal request");
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json() as ModPortalResponse;
|
||||
allMods = allMods.concat(data.results);
|
||||
|
||||
hasMorePages = currentPage < data.pagination.page_count;
|
||||
currentPage += 1;
|
||||
}
|
||||
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
|
||||
return allMods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full release list for a mod including dependencies.
|
||||
*
|
||||
* @param modName - Name of the mod to fetch the releases of
|
||||
* @returns Mod portal response
|
||||
*/
|
||||
static async fetchModReleases(modName: string) {
|
||||
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json() as ModsInfoResponse["results"][number];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import EventEmitter from "events";
|
||||
|
||||
/**
|
||||
* Type checked version of EventEmitter
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface Events {
|
||||
* "foo": (a: number, b: number) => void,
|
||||
* "bar": (spam: string) => void,
|
||||
* }
|
||||
*
|
||||
* class Emitter extends TypedEventEmitter<keyof Events, Events> {}
|
||||
*
|
||||
* const e = new Emitter();
|
||||
* e.emit("foo", 1, 2) // type checked arguments
|
||||
* ```
|
||||
*/
|
||||
export default abstract class TypedEventEmitter<
|
||||
Keys extends string,
|
||||
Events extends Record<Keys, (...args: [...any]) => void>,
|
||||
> extends EventEmitter {
|
||||
declare ["on"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["off"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["once"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["emit"]: <E extends Keys>(event: E, ...args: Parameters<Events[E]>) => boolean;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
/**
|
||||
* Represents an async function which can fetch a value for a ValueCache
|
||||
*/
|
||||
export interface ValueCacheFetch<T> {
|
||||
(): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory single value cache
|
||||
*
|
||||
* Stores the last time a value was fetched and measures staleness
|
||||
* Concurrent calls will share the same in-flight fetch
|
||||
*/
|
||||
export default class ValueCache<T> {
|
||||
private ongoingFetch: Promise<T> | null = null;
|
||||
private timestamp: number | null = null;
|
||||
private value: T | null = null; // Only valid when timestamp is not null
|
||||
|
||||
/**
|
||||
* Create a new in memory single value cache
|
||||
*
|
||||
* @param fetch A function that fetches and returns a fresh value
|
||||
*/
|
||||
constructor(
|
||||
private readonly fetch: ValueCacheFetch<T>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the cached value, will fetch a fresh value if stale or missing
|
||||
*
|
||||
* @param maxAgeMs Maximum allowed cache age in milliseconds.
|
||||
* Defaults to 0, meaning it will always fetch a new value.
|
||||
* @throws TypeError if maxAgeMs is invalid
|
||||
* @throws Propagates errors from underlying fetch method
|
||||
*/
|
||||
get(maxAgeMs: number = 0): Promise<T> {
|
||||
if (maxAgeMs < 0) {
|
||||
throw new TypeError("maxAgeMs must be a non-negative number");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (this.timestamp && now - this.timestamp < maxAgeMs) {
|
||||
return Promise.resolve(this.value as T);
|
||||
}
|
||||
|
||||
if (this.ongoingFetch) {
|
||||
return this.ongoingFetch;
|
||||
}
|
||||
|
||||
this.ongoingFetch = this.fetch()
|
||||
.then(value => {
|
||||
this.timestamp = Date.now();
|
||||
this.value = value;
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
this.ongoingFetch = null;
|
||||
});
|
||||
|
||||
return this.ongoingFetch;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
// Configuration classes
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import TypedEventEmitter from "../TypedEventEmitter";
|
||||
import * as fs from "fs-extra";
|
||||
import events from "events";
|
||||
|
||||
import isDeepStrictEqual from "../is_deep_strict_equal";
|
||||
import { basicType } from "../helpers";
|
||||
import * as libSchema from "../schema";
|
||||
import { StringEnum } from "../data/composites";
|
||||
import { safeOutputFile } from "../file_ops";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
@@ -150,7 +152,7 @@ function defaultValue(def: FieldDefinition) {
|
||||
}
|
||||
|
||||
export type ConfigEvents = {
|
||||
fieldChanged: (name: string, curr: FieldValue, prev: FieldValue) => void;
|
||||
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
||||
}
|
||||
|
||||
export enum ConfigAccess {
|
||||
@@ -176,10 +178,7 @@ export enum ConfigAccess {
|
||||
*/
|
||||
export class Config<
|
||||
Fields extends { [Field in keyof Fields]: FieldValue },
|
||||
> extends TypedEventEmitter<
|
||||
keyof ConfigEvents,
|
||||
ConfigEvents
|
||||
> {
|
||||
> extends events.EventEmitter<ConfigEvents> {
|
||||
/**
|
||||
* Mapping of config name to field meta data
|
||||
* Note that mutating this object will lead to unexpected behaviour.
|
||||
@@ -198,24 +197,32 @@ export class Config<
|
||||
return config;
|
||||
}
|
||||
|
||||
fields: Fields;
|
||||
_unknownFields: Record<string, FieldValue> = {};
|
||||
/** Defines the default access locations for the config class */
|
||||
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
|
||||
|
||||
/** Contains the fields and their values */
|
||||
private fields: Fields;
|
||||
private _unknownFields: Record<string, FieldValue> = {};
|
||||
|
||||
/** Set to true when a field in the config is changed. */
|
||||
dirty = false;
|
||||
private dirty = false;
|
||||
/** Set to true when a 'restart required' field in the config is changed. */
|
||||
restartRequired = false;
|
||||
|
||||
/**
|
||||
* Create a new instance of the given config
|
||||
*
|
||||
* @param location -
|
||||
* Location to evaluate access for this instance from
|
||||
* Location to evaluate access for this instance from.
|
||||
* @param fields -
|
||||
* Serialized representation of fields to load.
|
||||
* @param filepath -
|
||||
* Filepath for the config to save to.
|
||||
*/
|
||||
constructor(
|
||||
public location: ConfigLocation,
|
||||
fields?: Static<typeof Config.jsonSchema>,
|
||||
public defaultAccess: ConfigLocation[] = ["controller", "host", "control"],
|
||||
public filepath?: string,
|
||||
) {
|
||||
if (typeof location !== "string") {
|
||||
throw new Error("location must be a string");
|
||||
@@ -260,7 +267,7 @@ export class Config<
|
||||
|| (
|
||||
(!def.credential || mode & ConfigAccess.write)
|
||||
&& (!def.readonly || mode & ConfigAccess.read)
|
||||
&& !(def.access ?? this.defaultAccess).includes(location)
|
||||
&& !(def.access ?? this.constructor.defaultAccess).includes(location)
|
||||
)
|
||||
) {
|
||||
if (error) {
|
||||
@@ -272,8 +279,52 @@ export class Config<
|
||||
return true;
|
||||
}
|
||||
|
||||
static jsonSchema = ConfigSchema;
|
||||
/**
|
||||
* Set the value of a field, while also settings the required flags and events.
|
||||
* This method does not perform any validation on the value being set
|
||||
*
|
||||
* @param name - Name of the field to set
|
||||
* @param value - The new value of the field
|
||||
* @param notify - False to skip setting dirty flags and emitting events
|
||||
*/
|
||||
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
|
||||
const prev = this.fields[name];
|
||||
this.fields[name] = value as any;
|
||||
if (notify && !isDeepStrictEqual(value, prev)) {
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (def.restartRequired) {
|
||||
this.restartRequired = true;
|
||||
}
|
||||
this.dirty = true;
|
||||
this.emit("fieldChanged", name, value, prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config from file
|
||||
*
|
||||
* @param location - Location used for access control.
|
||||
* @param filepath - Filepath for the config to save to.
|
||||
* @returns Instance of this config
|
||||
*/
|
||||
static async fromFile(location: ConfigLocation, filepath: string) {
|
||||
const fields = await fs.readJson(filepath, { encoding: "utf8" });
|
||||
return this.fromJSON(fields, location, filepath);
|
||||
}
|
||||
|
||||
/** Saves this config to file if there are unsaved changes */
|
||||
async save() {
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
if (!this.filepath) {
|
||||
throw new Error("Cannot save config which has no filepath");
|
||||
}
|
||||
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"));
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
static jsonSchema = ConfigSchema;
|
||||
static validate = libSchema.compile(this.jsonSchema as any);
|
||||
|
||||
/**
|
||||
@@ -281,11 +332,13 @@ export class Config<
|
||||
*
|
||||
* @param json - Serialized config to load.
|
||||
* @param location - Location used for access control.
|
||||
* @param filepath - Filepath for the config to save to.
|
||||
* @returns Instance of this config
|
||||
*/
|
||||
static fromJSON(
|
||||
json: Static<typeof this.jsonSchema>,
|
||||
location: ConfigLocation,
|
||||
filepath?: string,
|
||||
) {
|
||||
// migrate: Pre alpha 14 config format
|
||||
if (typeof json === "object" && json !== null && json.groups instanceof Array) {
|
||||
@@ -301,7 +354,7 @@ export class Config<
|
||||
if (!valid) {
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
return new this(location, json);
|
||||
return new this(location, json, filepath);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -312,11 +365,16 @@ export class Config<
|
||||
* Serialize the config to a plain JavaScript object
|
||||
*
|
||||
* @param remote - Location this serialised representation is for
|
||||
* @param filter - When provided, only the given fields are serialized
|
||||
* @returns JSON serializable representation of the config.
|
||||
*/
|
||||
toRemote(remote: ConfigLocation): Static<typeof Config.jsonSchema> {
|
||||
toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static<typeof Config.jsonSchema> {
|
||||
let fields: Record<string, FieldValue> = {};
|
||||
for (let [name, value] of Object.entries(this.fields)) {
|
||||
if (filter && !filter.includes(name as keyof Fields)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (
|
||||
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|
||||
@@ -386,12 +444,7 @@ export class Config<
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev = this.fields[name];
|
||||
this.fields[name] = value as any;
|
||||
if (notify && !isDeepStrictEqual(value, prev)) {
|
||||
this.dirty = true;
|
||||
this.emit("fieldChanged", name, value, prev);
|
||||
}
|
||||
this._set(name, value, notify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,12 +559,7 @@ export class Config<
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
let prev = this.fields[name];
|
||||
this.fields[name] = value as Fields[Field];
|
||||
if (!isDeepStrictEqual(value, prev)) {
|
||||
this.dirty = true;
|
||||
this.emit("fieldChanged", name, value, prev);
|
||||
}
|
||||
this._set(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -554,10 +602,7 @@ export class Config<
|
||||
} else {
|
||||
delete updated[prop];
|
||||
}
|
||||
this.fields[name] = updated as Fields[Field];
|
||||
if (!isDeepStrictEqual(updated, prev)) {
|
||||
this.dirty = true;
|
||||
this.emit("fieldChanged", name, updated, prev);
|
||||
}
|
||||
|
||||
this._set(name, updated);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import * as classes from "./classes";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
|
||||
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
|
||||
|
||||
export interface ControllerConfigFields {
|
||||
"controller.version": string;
|
||||
"controller.name": string;
|
||||
"controller.mods_directory": string;
|
||||
"controller.database_directory": string;
|
||||
@@ -26,6 +30,12 @@ export interface ControllerConfigFields {
|
||||
"controller.default_mod_pack_id": number | null;
|
||||
"controller.default_role_id": number | null;
|
||||
"controller.autosave_interval": number;
|
||||
"controller.mod_portal_cache_duration_minutes": number;
|
||||
"controller.mod_portal_page_size": number;
|
||||
"controller.allow_remote_updates": boolean;
|
||||
"controller.allow_plugin_updates": boolean;
|
||||
"controller.allow_plugin_install": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +43,8 @@ export interface ControllerConfigFields {
|
||||
* @extends classes.Config
|
||||
*/
|
||||
export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControllerConfig;
|
||||
declare static fromJSON: configFromJSON<ControllerConfig>;
|
||||
declare static fromFile: configFromFile<ControllerConfig>;
|
||||
static migrations(config: Static<typeof this.jsonSchema>) {
|
||||
if (config.hasOwnProperty("controller.external_address")) {
|
||||
config["controller.public_url"] = config["controller.external_address"];
|
||||
@@ -44,6 +55,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
}
|
||||
|
||||
static fieldDefinitions: classes.ConfigDefs<ControllerConfigFields> = {
|
||||
"controller.version": {
|
||||
type: "string",
|
||||
initialValue: "", // Set on start
|
||||
readonly: ["controller"],
|
||||
hidden: true,
|
||||
},
|
||||
"controller.name": {
|
||||
title: "Name",
|
||||
description: "Name of the cluster.",
|
||||
@@ -197,10 +214,44 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
optional: true,
|
||||
initialValue: 1,
|
||||
},
|
||||
"controller.mod_portal_cache_duration_minutes": {
|
||||
title: "Mod Portal Cache Duration",
|
||||
description: "Duration in minutes to cache mod portal API responses.",
|
||||
type: "number",
|
||||
initialValue: 30,
|
||||
},
|
||||
"controller.mod_portal_page_size": {
|
||||
title: "Mod Portal Page Size",
|
||||
description: "Maximum number of results per page when querying the Factorio mod portal API.",
|
||||
type: "number",
|
||||
initialValue: 1000,
|
||||
},
|
||||
"controller.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
readonly: ["controller"],
|
||||
hidden: true,
|
||||
},
|
||||
"controller.allow_plugin_updates": {
|
||||
description: "When true, allows a remote event to trigger a plugin update via npm",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
readonly: ["controller"],
|
||||
hidden: true,
|
||||
},
|
||||
"controller.allow_plugin_install": {
|
||||
description: "When true, allows a remote event to trigger a plugin install via npm",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
readonly: ["controller"],
|
||||
hidden: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostConfigFields {
|
||||
"host.version": string;
|
||||
"host.name": string;
|
||||
"host.id": number;
|
||||
"host.factorio_directory": string;
|
||||
@@ -214,6 +265,10 @@ export interface HostConfigFields {
|
||||
"host.factorio_username": string | null,
|
||||
"host.factorio_token": string | null,
|
||||
"host.max_reconnect_delay": number;
|
||||
"host.allow_remote_updates": boolean;
|
||||
"host.allow_plugin_updates": boolean;
|
||||
"host.allow_plugin_install": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,8 +276,15 @@ export interface HostConfigFields {
|
||||
* @extends classes.Config
|
||||
*/
|
||||
export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => HostConfig;
|
||||
declare static fromJSON: configFromJSON<HostConfig>;
|
||||
declare static fromFile: configFromFile<HostConfig>;
|
||||
static fieldDefinitions: classes.ConfigDefs<HostConfigFields> = {
|
||||
"host.version": {
|
||||
type: "string",
|
||||
initialValue: "", // Set on start
|
||||
readonly: ["host"],
|
||||
hidden: true,
|
||||
},
|
||||
"host.name": {
|
||||
description: "Name of the host",
|
||||
type: "string",
|
||||
@@ -309,6 +371,27 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
},
|
||||
"host.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
readonly: ["host"],
|
||||
hidden: true,
|
||||
},
|
||||
"host.allow_plugin_updates": {
|
||||
description: "When true, allows a remote event to trigger a plugin update via npm",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
readonly: ["host"],
|
||||
hidden: true,
|
||||
},
|
||||
"host.allow_plugin_install": {
|
||||
description: "When true, allows a remote event to trigger a plugin install via npm",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
readonly: ["host"],
|
||||
hidden: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,6 +400,8 @@ export interface InstanceConfigFields {
|
||||
"instance.id": number;
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
|
||||
"factorio.version": string;
|
||||
"factorio.executable_path": string | null;
|
||||
@@ -333,6 +418,7 @@ export interface InstanceConfigFields {
|
||||
"factorio.enable_authserver_bans": boolean;
|
||||
"factorio.settings": Record<string, unknown>;
|
||||
"factorio.verbose_logging": boolean;
|
||||
"factorio.console_logging": boolean;
|
||||
"factorio.strip_paths": boolean;
|
||||
"factorio.sync_adminlist": "enabled" | "disabled" | "bidirectional";
|
||||
"factorio.sync_whitelist": "enabled" | "disabled" | "bidirectional";
|
||||
@@ -346,7 +432,8 @@ export interface InstanceConfigFields {
|
||||
* @extends classes.Config
|
||||
*/
|
||||
export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => InstanceConfig;
|
||||
declare static fromJSON: configFromJSON<InstanceConfig>;
|
||||
declare static fromFile: configFromFile<InstanceConfig>;
|
||||
static migrations(config: Static<typeof this.jsonSchema>) {
|
||||
function boolToEnableDisable(name: string) {
|
||||
if (config.hasOwnProperty(name) && typeof config[name] === "boolean") {
|
||||
@@ -382,11 +469,17 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.exclude_from_start_all": {
|
||||
description: "Exclude this instance from the 'Start all' button operation",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
@@ -461,12 +554,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
},
|
||||
"factorio.enable_whitelist": {
|
||||
description: "Turn on whitelist for joining the server.",
|
||||
restartRequired: true, // Because of the cli option "--use-server-whitelist"
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.enable_authserver_bans": {
|
||||
description: "Turn on Factorio.com based multiplayer bans.",
|
||||
restartRequired: true,
|
||||
restartRequired: true, // Because of the cli option "--use-authserver-bans"
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
@@ -484,7 +578,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
},
|
||||
"factorio.verbose_logging": {
|
||||
description: "Enable verbose logging on the Factorio server",
|
||||
restartRequired: true,
|
||||
restartRequired: true, // Because of the cli option "--verbose"
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.console_logging": {
|
||||
description: "Enable console logging to a separate file, useful for 3rd party integrations",
|
||||
restartRequired: true, // Because of the cli option "--console-log"
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
@@ -501,10 +601,10 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
initialValue: "bidirectional",
|
||||
},
|
||||
"factorio.sync_whitelist": {
|
||||
description: "Synchronize whitelist with the controller",
|
||||
description: "Synchronize whitelist with the controller (whitelist must be enabled)",
|
||||
type: "string",
|
||||
enum: ["disabled", "enabled"], // TODO: Implement bidirectional
|
||||
initialValue: "enabled",
|
||||
enum: ["disabled", "enabled", "bidirectional"],
|
||||
initialValue: "bidirectional",
|
||||
},
|
||||
"factorio.sync_banlist": {
|
||||
description: "Synchronize banlist with the controller",
|
||||
@@ -533,7 +633,8 @@ export interface ControlConfigFields {
|
||||
* @extends classes.Config
|
||||
*/
|
||||
export class ControlConfig extends classes.Config<ControlConfigFields> {
|
||||
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControlConfig;
|
||||
declare static fromJSON: configFromJSON<ControlConfig>;
|
||||
declare static fromFile: configFromFile<ControlConfig>;
|
||||
static fieldDefinitions: classes.ConfigDefs<ControlConfigFields> = {
|
||||
"control.controller_url": {
|
||||
description: "URL to connect to the controller at",
|
||||
|
||||
@@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import * as libHash from "../hash";
|
||||
import * as libSchema from "../schema";
|
||||
import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
|
||||
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
|
||||
};
|
||||
|
||||
const lettersRegex = /^[a-zA-Z]+$/;
|
||||
|
||||
export class ModDependency {
|
||||
public type: ModDependencyType;
|
||||
public name: string;
|
||||
public version: ModVersionEquality | undefined;
|
||||
|
||||
static getTypeFromPrefix(prefix: string): ModDependencyType {
|
||||
switch (prefix) {
|
||||
case "!":
|
||||
return "incompatible";
|
||||
case "?":
|
||||
return "optional";
|
||||
case "(?)":
|
||||
return "hidden";
|
||||
case "~":
|
||||
return "unordered";
|
||||
case "":
|
||||
return "required";
|
||||
default:
|
||||
throw new Error(`Invalid dependency prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
public specification: string,
|
||||
) {
|
||||
const parts = specification.split(" ");
|
||||
|
||||
// Fix mods which include spaces in their name, joins consecutive words together
|
||||
let index = 0;
|
||||
while (index < parts.length - 1) {
|
||||
const current = parts[index];
|
||||
const next = parts[index + 1];
|
||||
if (lettersRegex.test(current) && lettersRegex.test(next)) {
|
||||
parts[index] = `${current} ${next}`;
|
||||
parts.splice(index + 1, 1);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the dependency specification
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
break;
|
||||
case 2:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
break;
|
||||
case 3:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
|
||||
break;
|
||||
case 4:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid dependency specification: ${specification}`);
|
||||
}
|
||||
}
|
||||
|
||||
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
|
||||
const mod = mods.find(m => m.name === this.name);
|
||||
if (mod === undefined) {
|
||||
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
|
||||
} else if (this.type === "incompatible") {
|
||||
return "incompatible";
|
||||
} else if (this.version && !this.version.testVersion(mod.version)) {
|
||||
return "wrong_version";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isSatisfied(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.checkUnsatisfiedReason(mods) === undefined;
|
||||
}
|
||||
|
||||
get incompatible() {
|
||||
return this.type === "incompatible";
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.type === "unordered" || this.type === "required";
|
||||
}
|
||||
|
||||
get optional() {
|
||||
return this.type === "hidden" || this.type === "optional";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.specification;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info about a mod available on the controller.
|
||||
@@ -35,13 +154,13 @@ export default class ModInfo {
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
version = "";
|
||||
version = "0.0.0" as FullVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerModVersion(this.version);
|
||||
return integerFullVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,21 +197,38 @@ export default class ModInfo {
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
factorioVersion = "0.12";
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependiences for this mod.
|
||||
* Dependencies for this mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
dependencies = ["base"];
|
||||
dependencies = [new ModDependency("base")];
|
||||
|
||||
/**
|
||||
* Dependencies for this mod.
|
||||
* As they would be represented in info.json.
|
||||
*/
|
||||
get dependencySpecifications() {
|
||||
return this.dependencies.map(d => d.specification);
|
||||
}
|
||||
|
||||
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.dependencies
|
||||
.map(d => d.checkUnsatisfiedReason(mods))
|
||||
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
|
||||
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
|
||||
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
|
||||
), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected name of zip file containing this mod.
|
||||
@@ -107,7 +243,7 @@ export default class ModInfo {
|
||||
* @param version - Mod's version
|
||||
* @returns string containing {name}_{version}.zip
|
||||
*/
|
||||
static filename(name: string, version: string) {
|
||||
static filename(name: string, version: FullVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -137,13 +273,13 @@ export default class ModInfo {
|
||||
// Content of info.json found in mod files
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -172,7 +308,7 @@ export default class ModInfo {
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
|
||||
|
||||
// Additional data
|
||||
if (json.size) { modInfo.size = json.size; }
|
||||
@@ -195,8 +331,8 @@ export default class ModInfo {
|
||||
if (this.homepage) { json.homepage = this.homepage; }
|
||||
if (this.description) { json.description = this.description; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
|
||||
json.dependencies = this.dependencies;
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
if (this.size) { json.size = this.size; }
|
||||
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
|
||||
@@ -227,10 +363,6 @@ export default class ModInfo {
|
||||
throw new Error("Mod's info.json is not valid");
|
||||
}
|
||||
|
||||
if (!modVersionRegExp.test(modInfo.version)) {
|
||||
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(modPath);
|
||||
return this.fromJSON({
|
||||
...modInfo,
|
||||
|
||||
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import { integerFactorioVersion } from "./version";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
export const ModSettingColor = Type.Object({
|
||||
r: Type.Number(),
|
||||
@@ -40,17 +46,21 @@ export interface ModRecord {
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: string,
|
||||
version: FullVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
error?: "missing"|"bad_checksum",
|
||||
error?: "missing" | "bad_checksum",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
|
||||
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
|
||||
info?: ModInfo,
|
||||
}
|
||||
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -82,13 +92,13 @@ export default class ModPack {
|
||||
/**
|
||||
* Version of Factorio this mod pack is for
|
||||
*/
|
||||
factorioVersion = "1.1.0";
|
||||
factorioVersion = "1.1.0" as PartialVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +146,7 @@ export default class ModPack {
|
||||
"id": Type.Optional(Type.Integer()),
|
||||
"name": Type.String(),
|
||||
"description": Type.String(),
|
||||
"factorio_version": Type.String(),
|
||||
"factorio_version": PartialVersionSchema,
|
||||
"mods": Type.Array(ModRecordJsonSchema),
|
||||
"settings": Type.Object({
|
||||
"startup": ModSettingsJsonSchema,
|
||||
@@ -161,7 +171,7 @@ export default class ModPack {
|
||||
if (json.name) { modPack.name = json.name; }
|
||||
if (json.description) { modPack.description = json.description; }
|
||||
if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map((m: ModRecord) => [m.name, m])); }
|
||||
if (json.settings) {
|
||||
modPack.settings = {
|
||||
"startup": new Map(Object.entries(json.settings["startup"])),
|
||||
@@ -218,7 +228,7 @@ export default class ModPack {
|
||||
let buf = Buffer.from(modPackString, "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed mod pack string: zlib inflate failed");
|
||||
@@ -291,7 +301,7 @@ export default class ModPack {
|
||||
const sizeBytes = uint32Bytes(entries.length);
|
||||
const itemBytes = entries.flatMap(([key, item]) => [
|
||||
immutableStringBytes(key),
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
propertyTreeBytes(item),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
@@ -385,39 +395,40 @@ export default class ModPack {
|
||||
|
||||
/** Array of default mod packs which should exist on a newly installed cluster */
|
||||
static defaultModPacks = [
|
||||
...["0.17", "0.18", "1.0", "1.1", "2.0"]
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Base Game ${version}`,
|
||||
description: `Factorio ${version} with no extra mods.`,
|
||||
factorio_version: version,
|
||||
} as any)),
|
||||
...["2.0"]
|
||||
...(["2.0"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Space Age ${version}`,
|
||||
description: `Factorio ${version} with Space Age expansion.`,
|
||||
factorio_version: version,
|
||||
mods: [
|
||||
{ name: "elevated-rails", enabled: true, version },
|
||||
{ name: "quality", enabled: true, version },
|
||||
{ name: "space-age", enabled: true, version },
|
||||
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns an array of ModInfo containing all mods included in factorio
|
||||
* Returns an array of ModRecord containing all mods included in factorio
|
||||
*
|
||||
* @param factorioVersion - Factorio version to get the default mods for
|
||||
* @return Built in mods for the given version
|
||||
*/
|
||||
static getBuiltinMods(factorioVersion: string) {
|
||||
static getBuiltinMods(factorioVersion: PartialVersion) {
|
||||
factorioVersion = normaliseFullVersion(factorioVersion);
|
||||
let defaultMods: ModRecord[] = [
|
||||
// "core" not included because core cannot be disabled
|
||||
{ name: "base", enabled: true, version: factorioVersion },
|
||||
];
|
||||
|
||||
const integerVersion = integerFactorioVersion(factorioVersion);
|
||||
if (integerVersion >= integerFactorioVersion("1.2")) {
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
if (integerVersion >= integerPartialVersion("1.2")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "elevated-rails", enabled: false, version: factorioVersion },
|
||||
{ name: "quality", enabled: false, version: factorioVersion },
|
||||
@@ -428,7 +439,7 @@ export default class ModPack {
|
||||
return defaultMods;
|
||||
}
|
||||
|
||||
static getBuiltinModNames(factorioVersion: string) {
|
||||
static getBuiltinModNames(factorioVersion: PartialVersion) {
|
||||
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export default class Role {
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: Set<string>,
|
||||
public updatedAtMs = 0,
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -17,22 +19,18 @@ export default class Role {
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static remapPermission(permission: string) {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
if (permission === "core.instance.save.list.subscribe") {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.id,
|
||||
json.name,
|
||||
json.description,
|
||||
new Set(json.permissions.map(this.remapPermission)),
|
||||
new Set(json.permissions),
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +40,8 @@ export default class Role {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
permissions: [...this.permissions],
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ export function StringEnum<T extends string[]>(values: [...T]) {
|
||||
return Type.Unsafe<T[number]>({ type: "string", enum: values });
|
||||
}
|
||||
|
||||
export function StringKey<T extends string>(object: Record<T, any>) {
|
||||
return Type.Unsafe<keyof typeof object>({ type: "string", enum: Object.keys(object) });
|
||||
}
|
||||
|
||||
export interface JSONDeserialisable<T> {
|
||||
fromJSON(json: unknown): T;
|
||||
jsonSchema: object;
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
* @author Hornwitser
|
||||
*/
|
||||
export { default as ExportManifest } from "./ExportManifest";
|
||||
export { default as ModInfo } from "./ModInfo";
|
||||
export { default as ModPack, ModSetting, ModSettingColor, ModRecord } from "./ModPack";
|
||||
export { default as ModInfo, ModDependency } from "./ModInfo";
|
||||
export { ModSettingColor } from "./ModPack";
|
||||
export type { ModSetting, ModRecord } from "./ModPack";
|
||||
export { default as ModPack } from "./ModPack";
|
||||
export { default as ModuleInfo } from "./ModuleInfo";
|
||||
export { default as Permission } from "./Permission";
|
||||
export { default as PlayerStats } from "./PlayerStats";
|
||||
export { default as Role } from "./Role";
|
||||
export { default as User, IControllerUser } from "./User";
|
||||
export { default as User } from "./User";
|
||||
export type { IControllerUser } from "./User";
|
||||
export * from "./composites";
|
||||
export * from "./messages_core";
|
||||
export * from "./messages_controller";
|
||||
@@ -18,4 +21,6 @@ export * from "./messages_host";
|
||||
export * from "./messages_instance";
|
||||
export * from "./messages_mod";
|
||||
export * from "./messages_user";
|
||||
export * from "./messages_role";
|
||||
export * from "./messages_plugin";
|
||||
export * from "./version";
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonString, StringEnum, plainJson } from "./composites";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
||||
import { levels } from "../logging";
|
||||
import { ControllerConfig, HostConfig } from "../config";
|
||||
import { ExternalFactorioVersionSchema } from "../external";
|
||||
|
||||
export class ControllerStopRequest {
|
||||
declare ["constructor"]: typeof ControllerStopRequest;
|
||||
@@ -19,6 +20,14 @@ export class ControllerRestartRequest {
|
||||
static permission = "core.controller.restart" as const;
|
||||
}
|
||||
|
||||
export class ControllerUpdateRequest {
|
||||
declare ["constructor"]: typeof ControllerUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.controller.update" as const;
|
||||
}
|
||||
|
||||
export class ControllerConfigGetRequest {
|
||||
declare ["constructor"]: typeof ControllerConfigGetRequest;
|
||||
static type = "request" as const;
|
||||
@@ -217,12 +226,12 @@ export class LogMessageEvent {
|
||||
static dst = ["controller", "control"] as const;
|
||||
|
||||
constructor(
|
||||
public info: { level: string, message: string },
|
||||
public info: { level: keyof typeof levels, message: string },
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"info": Type.Object({
|
||||
"level": Type.String(),
|
||||
"level": StringKey(levels),
|
||||
"message": Type.String(),
|
||||
}),
|
||||
});
|
||||
@@ -250,6 +259,7 @@ export class SystemInfo {
|
||||
public diskCapacity: number,
|
||||
public diskAvailable: number,
|
||||
public canRestart: boolean,
|
||||
public restartRequired: boolean,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
@@ -268,6 +278,7 @@ export class SystemInfo {
|
||||
"diskCapacity": Type.Number(),
|
||||
"diskAvailable": Type.Number(),
|
||||
"canRestart": Type.Boolean(),
|
||||
"restartRequired": Type.Boolean(),
|
||||
"updatedAtMs": Type.Number(),
|
||||
"isDeleted": Type.Boolean(),
|
||||
});
|
||||
@@ -286,6 +297,7 @@ export class SystemInfo {
|
||||
json.diskCapacity,
|
||||
json.diskAvailable,
|
||||
json.canRestart,
|
||||
json.restartRequired,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
@@ -380,3 +392,25 @@ export class DebugWsMessageEvent {
|
||||
return new this(json.direction, json.content);
|
||||
}
|
||||
}
|
||||
|
||||
export class FactorioVersionsRequest {
|
||||
declare ["constructor"]: typeof FactorioVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_factorio_versions" as const;
|
||||
|
||||
constructor(
|
||||
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"maxAgeMs": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Note: Run compile_validator script after making changes to this file.
|
||||
import { TSchema, Type, Static } from "@sinclair/typebox";
|
||||
// eslint-disable-next-line node/no-missing-import
|
||||
|
||||
import messageValidate from "./message_validate"; // generated file
|
||||
import { StringEnum } from "./composites";
|
||||
|
||||
@@ -154,7 +154,7 @@ export class Message {
|
||||
static validate = messageValidate;
|
||||
|
||||
static fromJSON(json: any) {
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
if (json.type === "hello") { return MessageHello.fromJSON(json); }
|
||||
if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); }
|
||||
if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); }
|
||||
@@ -168,7 +168,7 @@ export class Message {
|
||||
if (json.type === "responseError") { return MessageResponseError.fromJSON(json); }
|
||||
if (json.type === "event") { return MessageEvent.fromJSON(json); }
|
||||
if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); }
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
||||
throw new Error(`Unrecognized message type ${json.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ export class HostRestartRequest {
|
||||
static permission = "core.host.restart" as const;
|
||||
}
|
||||
|
||||
export class HostUpdateRequest {
|
||||
declare ["constructor"]: typeof HostUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "host" as const;
|
||||
static permission = "core.host.update" as const;
|
||||
}
|
||||
|
||||
export class HostConfigGetRequest {
|
||||
declare ["constructor"]: typeof HostConfigGetRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InstanceConfig } from "../config";
|
||||
import type { IControllerUser } from "./User";
|
||||
import type { MessageRequest } from "./messages_core";
|
||||
import { CollectorResultSerialized } from "../prometheus";
|
||||
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
|
||||
|
||||
|
||||
export const InstanceStatus = StringEnum([
|
||||
@@ -42,9 +43,10 @@ export class InstanceDetails {
|
||||
public assignedHost: number | undefined,
|
||||
public gamePort: number | undefined,
|
||||
public status: InstanceStatus,
|
||||
public factorioVersion: string | undefined,
|
||||
public factorioVersion: TargetVersion | undefined,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -56,8 +58,9 @@ export class InstanceDetails {
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
]),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -69,6 +72,7 @@ export class InstanceDetails {
|
||||
json.status,
|
||||
json.factorioVersion,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,14 +141,16 @@ export class InstanceCreateRequest {
|
||||
|
||||
constructor(
|
||||
public config: Static<typeof InstanceConfig.jsonSchema>,
|
||||
public cloneFromId?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": InstanceConfig.jsonSchema,
|
||||
"cloneFromId": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.config);
|
||||
return new this(json.config, json.cloneFromId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +288,26 @@ export class InstanceStartRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceRestartRequest {
|
||||
declare ["constructor"]: typeof InstanceRestartRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.restart" as const;
|
||||
|
||||
constructor(
|
||||
public save?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"save": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.save);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveDetails {
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
@@ -719,8 +745,8 @@ export class HostInstanceUpdate {
|
||||
constructor(
|
||||
public config: Static<typeof InstanceConfig.jsonSchema>,
|
||||
public status: InstanceStatus,
|
||||
public gamePort: undefined | number,
|
||||
public factorioVersion: undefined | string,
|
||||
public gamePort: number | undefined,
|
||||
public factorioVersion: PartialVersion | undefined,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -729,7 +755,7 @@ export class HostInstanceUpdate {
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]),
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(PartialVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -827,7 +853,7 @@ export class InstanceStatusChangedEvent {
|
||||
public instanceId: number,
|
||||
public status: InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: string,
|
||||
public factorioVersion?: TargetVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -836,7 +862,7 @@ export class InstanceStatusChangedEvent {
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]),
|
||||
"gamePort": Type.Optional(Type.Integer()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import ModInfo from "./ModInfo";
|
||||
import ModInfo, { ModDependency } from "./ModInfo";
|
||||
import ModPack from "./ModPack";
|
||||
import { JsonString, jsonArray } from "./composites";
|
||||
|
||||
import {
|
||||
FullVersion, FullVersionSchema,
|
||||
ApiVersion, ApiVersionSchema, normaliseApiVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
|
||||
export class ModPackGetRequest {
|
||||
declare ["constructor"]: typeof ModPackGetRequest;
|
||||
@@ -113,13 +119,13 @@ export class ModGetRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -148,7 +154,7 @@ export class ModSearchRequest {
|
||||
|
||||
constructor(
|
||||
public query: string,
|
||||
public factorioVersion: string,
|
||||
public factorioVersion: ApiVersion,
|
||||
public page: number,
|
||||
public pageSize?: number,
|
||||
public sort?: string,
|
||||
@@ -157,7 +163,7 @@ export class ModSearchRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"query": Type.String(),
|
||||
"factorioVersion": Type.String(),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"page": Type.Integer(),
|
||||
"pageSize": Type.Optional(Type.Integer()),
|
||||
"sort": Type.Optional(Type.String()),
|
||||
@@ -197,6 +203,70 @@ export class ModSearchRequest {
|
||||
};
|
||||
}
|
||||
|
||||
// Define the structure for the latest release info from the portal
|
||||
export const ModPortalReleaseSchema = Type.Object({
|
||||
version: FullVersionSchema,
|
||||
// Match the structure from ModStore's ModRelease/ModDetails
|
||||
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
|
||||
released_at: Type.String(), // ISO 8601 date string
|
||||
download_url: Type.String(),
|
||||
file_name: Type.String(),
|
||||
sha1: Type.String(),
|
||||
});
|
||||
|
||||
// Define the structure for mod details returned by the portal API
|
||||
// This should align with the ModDetails interface in ModStore.ts
|
||||
export const ModPortalDetailsSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
title: Type.String(),
|
||||
summary: Type.String(),
|
||||
owner: Type.String(),
|
||||
downloads_count: Type.Integer(),
|
||||
category: Type.Optional(Type.String()),
|
||||
score: Type.Optional(Type.Number()),
|
||||
latest_release: Type.Optional(ModPortalReleaseSchema),
|
||||
releases: Type.Optional(Type.Array(ModPortalReleaseSchema)),
|
||||
});
|
||||
|
||||
export class ModPortalGetAllRequest {
|
||||
declare ["constructor"]: typeof ModPortalGetAllRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public factorioVersion: ApiVersion,
|
||||
public hide_deprecated?: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"hide_deprecated": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.factorioVersion, json.hide_deprecated);
|
||||
}
|
||||
|
||||
// Define the Response class inline
|
||||
static Response = class ModPortalGetAllResponse {
|
||||
declare ["constructor"]: typeof ModPortalGetAllResponse;
|
||||
constructor(
|
||||
public mods: Static<typeof ModPortalDetailsSchema>[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
mods: Type.Array(ModPortalDetailsSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
// No transformation needed if schema matches API structure
|
||||
return new this(json.mods);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class ModDownloadRequest {
|
||||
declare ["constructor"]: typeof ModDownloadRequest;
|
||||
static type = "request" as const;
|
||||
@@ -206,13 +276,13 @@ export class ModDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -232,12 +302,12 @@ export class ModDeleteRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -284,3 +354,120 @@ export class ModUpdatesEvent {
|
||||
return new this(json.updates.map(update => ModInfo.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModNameVersionPair {
|
||||
name: string,
|
||||
version: ModVersionEquality,
|
||||
}
|
||||
|
||||
export const ModNameVersionPairSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": ModVersionEquality.jsonSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Request mods to be downloaded from the Factorio mod portal to the controller.
|
||||
*
|
||||
* The controller handles the actual download process asynchronously.
|
||||
* Requires Factorio credentials to be configured on the controller if the portal
|
||||
* requires authentication for downloads.
|
||||
*
|
||||
* @param mods - Array of mods to be downloaded.
|
||||
* @param factorioVersion - Factorio version context for the download.
|
||||
*/
|
||||
export class ModPortalDownloadRequest {
|
||||
declare ["constructor"]: typeof ModPortalDownloadRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.download_from_portal" as const;
|
||||
static Response = jsonArray(ModInfo);
|
||||
|
||||
constructor(
|
||||
public mods: ModNameVersionPair[],
|
||||
public factorioVersion: ApiVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModNameVersionPairSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.mods.map(mod => ({
|
||||
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
|
||||
})),
|
||||
json.factorioVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request mods dependencies to be resolved using the Factorio mod portal.
|
||||
*
|
||||
* @param mods - Array of dependencies to resolve.
|
||||
* @param factorioVersion - Factorio version context for resolution.
|
||||
*/
|
||||
export class ModDependencyResolveRequest {
|
||||
declare ["constructor"]: typeof ModDependencyResolveRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public mods: ModDependency[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public checkForUpdates: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModDependency.jsonSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"checkForUpdates": Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
|
||||
}
|
||||
|
||||
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
|
||||
const equality = checkForUpdates ? ">=" : "=";
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
|
||||
const equality = checkForUpdates ? ">=" : "=";
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.filter(mod => mod.enabled)
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
static Response = class ModDependencyResolveResponse {
|
||||
constructor(
|
||||
public dependencies: ModInfo[],
|
||||
public incompatible: string[],
|
||||
public missing: string[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"dependencies": Type.Array(ModInfo.jsonSchema),
|
||||
"incompatible": Type.Array(Type.String()),
|
||||
"missing": Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { jsonArray } from "./composites";
|
||||
import { PluginNodeEnvInfo } from "../plugin";
|
||||
|
||||
/* This is similar to other plugin definitions but with lots removed */
|
||||
export class PluginDetails {
|
||||
constructor(
|
||||
public name: string,
|
||||
public title: string,
|
||||
public version: string,
|
||||
public loaded: boolean,
|
||||
public enabled: boolean,
|
||||
public description?: string,
|
||||
public npmPackage?: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
title: Type.String(),
|
||||
version: Type.String(),
|
||||
loaded: Type.Boolean(),
|
||||
enabled: Type.Boolean(),
|
||||
description: Type.Optional(Type.String()),
|
||||
npmPackage: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.title, json.version, json.loaded, json.enabled,
|
||||
json.description, json.npmPackage
|
||||
);
|
||||
}
|
||||
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
|
||||
return new this(
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
|
||||
pluginInfo.description, pluginInfo.npmPackage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginListRequest {
|
||||
declare ["constructor"]: typeof PluginListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = ["controller", "host"] as const;
|
||||
static permission = "core.plugin.list";
|
||||
static Response = jsonArray(PluginDetails);
|
||||
}
|
||||
|
||||
export class PluginUpdateRequest {
|
||||
declare ["constructor"]: typeof PluginUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = ["controller", "host"] as const;
|
||||
static permission = "core.plugin.update";
|
||||
|
||||
constructor(
|
||||
public pluginPackage: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.pluginPackage;
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginInstallRequest {
|
||||
declare ["constructor"]: typeof PluginInstallRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = ["controller", "host"] as const;
|
||||
static permission = "core.plugin.install";
|
||||
|
||||
constructor(
|
||||
public pluginPackage: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.pluginPackage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonNumber, jsonArray } from "./composites";
|
||||
import Permission from "./Permission";
|
||||
import Role from "./Role";
|
||||
|
||||
export class PermissionListRequest {
|
||||
declare ["constructor"]: typeof PermissionListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.permission.list" as const;
|
||||
static Response = jsonArray(Permission);
|
||||
}
|
||||
|
||||
export class RoleListRequest {
|
||||
declare ["constructor"]: typeof RoleListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.list" as const;
|
||||
static Response = jsonArray(Role);
|
||||
}
|
||||
|
||||
export class RoleUpdatesEvent {
|
||||
declare ["constructor"]: typeof RoleUpdatesEvent;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "control" as const;
|
||||
static permission = "core.role.subscribe" as const;
|
||||
|
||||
constructor(
|
||||
public updates: Role[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(Role.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => Role.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleCreateRequest {
|
||||
declare ["constructor"]: typeof RoleCreateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.create" as const;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.description, json.permissions);
|
||||
}
|
||||
|
||||
static Response = JsonNumber;
|
||||
}
|
||||
|
||||
export class RoleUpdateRequest {
|
||||
declare ["constructor"]: typeof RoleUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.name, json.description, json.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleGrantDefaultPermissionsRequest {
|
||||
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleDeleteRequest {
|
||||
declare ["constructor"]: typeof RoleDeleteRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.delete" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,7 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import Permission from "./Permission";
|
||||
import Role from "./Role";
|
||||
import User from "./User";
|
||||
import { JsonNumber, jsonArray } from "./composites";
|
||||
|
||||
export class PermissionListRequest {
|
||||
declare ["constructor"]: typeof PermissionListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.permission.list" as const;
|
||||
static Response = jsonArray(Permission);
|
||||
}
|
||||
|
||||
export class RoleListRequest {
|
||||
declare ["constructor"]: typeof RoleListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.list" as const;
|
||||
static Response = jsonArray(Role);
|
||||
}
|
||||
|
||||
export class RoleCreateRequest {
|
||||
declare ["constructor"]: typeof RoleCreateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.create" as const;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.description, json.permissions);
|
||||
}
|
||||
|
||||
static Response = JsonNumber;
|
||||
}
|
||||
|
||||
export class RoleUpdateRequest {
|
||||
declare ["constructor"]: typeof RoleUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.name, json.description, json.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleGrantDefaultPermissionsRequest {
|
||||
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleDeleteRequest {
|
||||
declare ["constructor"]: typeof RoleDeleteRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.delete" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
import User, { IControllerUser } from "./User";
|
||||
import { StringEnum, jsonArray, plainJson } from "./composites";
|
||||
import { MessageRequest } from "./messages_core";
|
||||
|
||||
export class UserGetRequest {
|
||||
declare ["constructor"]: typeof UserGetRequest;
|
||||
@@ -320,3 +209,129 @@ export class UserUpdatesEvent {
|
||||
return new this(json.updates.map(update => User.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterioUserExport {
|
||||
public export_version = "2.0.0-alpha.20"; // Only increment when jsonSchema changes
|
||||
|
||||
static clusterioUserSchema = Type.Object({
|
||||
"username": Type.String(),
|
||||
"is_admin": Type.Optional(Type.Boolean()),
|
||||
"is_whitelisted": Type.Optional(Type.Boolean()),
|
||||
"is_banned": Type.Optional(Type.Boolean()),
|
||||
"ban_reason": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static factorioUserSchema = Type.Union([
|
||||
Type.String(),
|
||||
Type.Object({
|
||||
"username": Type.String(),
|
||||
"reason": Type.String(),
|
||||
}),
|
||||
]);
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"export_version": Type.String(),
|
||||
"users": Type.Array(this.clusterioUserSchema),
|
||||
});
|
||||
|
||||
constructor(
|
||||
public users: Static<typeof ClusterioUserExport.jsonSchema.properties.users>,
|
||||
) { }
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
const obj = new this(json.users);
|
||||
obj.export_version = json.export_version;
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserBulkImportRequest {
|
||||
declare ["constructor"]: typeof UserBulkImportRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission(user: IControllerUser, message: MessageRequest) {
|
||||
if (typeof message.data === "object" && message.data !== null) {
|
||||
const data = message.data as Static<typeof UserBulkImportRequest.jsonSchema>;
|
||||
// Check if this is a restore or import request
|
||||
if (data.restore) {
|
||||
user.checkPermission("core.user.bulk_restore");
|
||||
} else {
|
||||
user.checkPermission("core.user.bulk_import");
|
||||
}
|
||||
// Check if they have the permission for that type
|
||||
switch (data.importType) {
|
||||
case "users":
|
||||
user.checkPermission("core.user.set_admin");
|
||||
user.checkPermission("core.user.set_banned");
|
||||
user.checkPermission("core.user.set_whitelisted");
|
||||
break;
|
||||
case "admins":
|
||||
user.checkPermission("core.user.set_admin");
|
||||
break;
|
||||
case "bans":
|
||||
user.checkPermission("core.user.set_banned");
|
||||
break;
|
||||
case "whitelist":
|
||||
user.checkPermission("core.user.set_whitelisted");
|
||||
break;
|
||||
default:
|
||||
// @ts-expect-error Unreachable
|
||||
throw new Error(`Unknown import / restore type: ${data.importType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Union([
|
||||
Type.Array(ClusterioUserExport.factorioUserSchema),
|
||||
ClusterioUserExport.jsonSchema,
|
||||
]));
|
||||
|
||||
constructor(
|
||||
public importType: "users" | "bans" | "admins" | "whitelist",
|
||||
public users: Static<typeof ClusterioUserExport.clusterioUserSchema>[]
|
||||
| Static<typeof ClusterioUserExport.factorioUserSchema>[],
|
||||
public restore?: boolean
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Union([
|
||||
Type.Object({
|
||||
"importType": StringEnum(["bans", "admins", "whitelist"]),
|
||||
"users": Type.Array(ClusterioUserExport.factorioUserSchema),
|
||||
"restore": Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
Type.Object({
|
||||
"importType": Type.Literal("users"),
|
||||
"users": Type.Array(ClusterioUserExport.clusterioUserSchema),
|
||||
"restore": Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
]);
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.importType, json.users, json.restore);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserBulkExportRequest {
|
||||
declare ["constructor"]: typeof UserBulkExportRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.user.bulk_export" as const;
|
||||
static Response = plainJson(Type.Union([
|
||||
Type.Array(ClusterioUserExport.factorioUserSchema),
|
||||
ClusterioUserExport.jsonSchema,
|
||||
]));
|
||||
|
||||
constructor(
|
||||
public exportType: "users" | "bans" | "admins" | "whitelist",
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"exportType": StringEnum(["users", "bans", "admins", "whitelist"]),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.exportType);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,241 @@
|
||||
// Helper functions for dealing with factorio version numbers
|
||||
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Integer representation of a mod version
|
||||
* Integer representation of a version string.
|
||||
*
|
||||
* Suitable for sorting and comparing version numbers which may have different
|
||||
* number of leading zeros in them.
|
||||
* @param version - string representation of version.
|
||||
* @returns numeric representation.
|
||||
*/
|
||||
export function integerModVersion(version: string) {
|
||||
export type IntegerVersion = number & { [integerVersionSymbol]: void };
|
||||
declare const integerVersionSymbol: unique symbol;
|
||||
|
||||
/**
|
||||
* Allowed version equalities as defined within mod info json
|
||||
* https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
|
||||
*/
|
||||
const versionEqualities = ["<", "<=", "=", ">=", ">"] as const;
|
||||
export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v)));
|
||||
export type VersionEquality = Static<typeof VersionEqualitySchema>;
|
||||
|
||||
export function isVersionEquality(input: string): input is VersionEquality {
|
||||
return versionEqualities.includes(input as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions accepts by the web API
|
||||
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
|
||||
*/
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
|
||||
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
|
||||
export type ApiVersion = Static<typeof ApiVersionSchema>;
|
||||
|
||||
export function isApiVersion(input: string): input is ApiVersion {
|
||||
return ApiVersions.includes(input as any);
|
||||
}
|
||||
|
||||
export function normaliseApiVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
const apiVersion = `${parts[0]}.${parts[1]}`;
|
||||
if (!isApiVersion(apiVersion)) {
|
||||
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
|
||||
}
|
||||
return apiVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid mod versions, where all parts are specified.
|
||||
*/
|
||||
const fullVersionRegExp = /^\d+\.\d+\.\d+$/;
|
||||
export type FullVersion = `${number}.${number}.${number}`;
|
||||
export const FullVersionSchema = Type.Unsafe<FullVersion>(
|
||||
Type.String({ pattern: fullVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isFullVersion(input: string): input is FullVersion {
|
||||
return fullVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerFullVersion(version: FullVersion) {
|
||||
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
|
||||
// Can't use bitwise here because this is 48-bits.
|
||||
return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion;
|
||||
}
|
||||
|
||||
export function normaliseFullVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer representation of a factorio version
|
||||
*
|
||||
* Suitable for sorting and comparing version numbers which may have different
|
||||
* number of leading zeros in them.
|
||||
* @param version - string representation of version.
|
||||
* @returns numeric representation.
|
||||
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
|
||||
*/
|
||||
export function integerFactorioVersion(version: string) {
|
||||
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
|
||||
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
|
||||
export type PartialVersion = FullVersion | `${number}.${number}`;
|
||||
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
|
||||
Type.String({ pattern: partialVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isPartialVersion(input: string): input is PartialVersion {
|
||||
return partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerPartialVersion(version: PartialVersion) {
|
||||
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
// Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0.
|
||||
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid mod versions.
|
||||
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
|
||||
*/
|
||||
export const modVersionRegExp = /^\d+\.\d+\.\d+$/;
|
||||
export type TargetVersion = PartialVersion | "latest";
|
||||
export const TargetVersionSchema = Type.Union([
|
||||
PartialVersionSchema,
|
||||
Type.Literal("latest"),
|
||||
]);
|
||||
|
||||
export function isTargetVersion(input: string): input is TargetVersion {
|
||||
return input === "latest" || partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a mod version paired with an equality which can be tested against
|
||||
*/
|
||||
export class ModVersionEquality {
|
||||
public integerVersion: IntegerVersion;
|
||||
|
||||
constructor(
|
||||
public equality: VersionEquality,
|
||||
public version: PartialVersion,
|
||||
) {
|
||||
this.integerVersion = integerPartialVersion(version);
|
||||
if (!isVersionEquality(this.equality)) {
|
||||
throw new Error("Invalid equality");
|
||||
}
|
||||
if (!isPartialVersion(this.version)) {
|
||||
throw new Error("Invalid version");
|
||||
}
|
||||
}
|
||||
|
||||
testIntegerVersion(other: IntegerVersion) {
|
||||
switch (this.equality) {
|
||||
case "<":
|
||||
return other < this.integerVersion;
|
||||
case "<=":
|
||||
return other <= this.integerVersion;
|
||||
case "=":
|
||||
return other === this.integerVersion;
|
||||
case ">=":
|
||||
return other >= this.integerVersion;
|
||||
case ">":
|
||||
return other > this.integerVersion;
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${this.equality} ${this.version}`;
|
||||
}
|
||||
|
||||
static fromString(version: string) {
|
||||
const parts = version.split(" ");
|
||||
return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
static fromParts(equality: string, version: string) {
|
||||
if (!isVersionEquality(equality)) {
|
||||
throw new Error(`Unknown version equality: ${equality}`);
|
||||
}
|
||||
if (!isPartialVersion(version)) {
|
||||
throw new Error(`Malformed version string: ${version}`);
|
||||
}
|
||||
return new this(equality, version);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return this.fromString(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a range of mod versions which can be tested against
|
||||
*/
|
||||
export class ModVersionRange {
|
||||
constructor(
|
||||
public minVersion = new ModVersionEquality(">=", "0.0.0"),
|
||||
public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"),
|
||||
) {
|
||||
if (minVersion.equality === "<" || minVersion.equality === "<=") {
|
||||
throw new Error("Minimum version can not use < or <=");
|
||||
}
|
||||
if (maxVersion.equality === ">" || maxVersion.equality === ">=") {
|
||||
throw new Error("Maximum version can not use > or >=");
|
||||
}
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion)
|
||||
&& this.maxVersion.testIntegerVersion(this.minVersion.integerVersion);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.maxVersion = new ModVersionEquality(">", "0.0.0");
|
||||
}
|
||||
|
||||
testIntegerVersion(other: IntegerVersion) {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
switch (other.equality) {
|
||||
case "<":
|
||||
if (other.integerVersion <= this.maxVersion.integerVersion) {
|
||||
this.maxVersion = other;
|
||||
}
|
||||
break;
|
||||
case "<=":
|
||||
if (other.integerVersion < this.maxVersion.integerVersion) {
|
||||
this.maxVersion = other;
|
||||
}
|
||||
break;
|
||||
case "=":
|
||||
if (this.testIntegerVersion(other.integerVersion)) {
|
||||
this.minVersion = other;
|
||||
this.maxVersion = other;
|
||||
} else {
|
||||
// Make invalid as it could not be satisfied
|
||||
this.invalidate();
|
||||
}
|
||||
break;
|
||||
case ">=":
|
||||
if (other.integerVersion > this.minVersion.integerVersion) {
|
||||
this.minVersion = other;
|
||||
}
|
||||
break;
|
||||
case ">":
|
||||
if (other.integerVersion >= this.minVersion.integerVersion) {
|
||||
this.minVersion = other;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
import { SubscribableValue } from "./subscriptions";
|
||||
import { logger } from "./logging";
|
||||
import { EventEmitter } from "stream";
|
||||
import { ControllerConfig } from "./config";
|
||||
import path from "path";
|
||||
|
||||
type DatastoreKey = string | number;
|
||||
type DatastoreValue = string | number | boolean | object;
|
||||
@@ -30,7 +30,7 @@ export class MemoryDatastoreProvider<
|
||||
V extends DatastoreValue,
|
||||
> extends DatastoreProvider<K, V> {
|
||||
constructor(
|
||||
private value: Map<K, V> = new Map()
|
||||
private value = new Map<K, V>()
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -39,34 +39,33 @@ export class MemoryDatastoreProvider<
|
||||
this.value = new Map(data);
|
||||
}
|
||||
|
||||
async load() {
|
||||
async load(): Promise<Map<K, V>> {
|
||||
return new Map(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Store all data within a json object file and validate the result against a schema
|
||||
export class JsonDatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
J, // Intermediate type returned from migrations
|
||||
> extends DatastoreProvider<K, V> {
|
||||
> extends DatastoreProvider<string, V> {
|
||||
constructor(
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any,
|
||||
private migrations: (rawJson: Record<DatastoreKey, unknown>) => Record<DatastoreKey, J> = v => v as any,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Save the data to the json file
|
||||
async save(data: Map<K, V>) {
|
||||
async save(data: Map<string, V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await safeOutputFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
async load() {
|
||||
async load(): Promise<Map<string, V>> {
|
||||
// Read the raw json from the file
|
||||
let rawJson;
|
||||
try {
|
||||
@@ -89,27 +88,26 @@ export class JsonDatastoreProvider<
|
||||
|
||||
// Store all data within a json array file where each element has a unique id
|
||||
export class JsonIdDatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue & { id: K },
|
||||
V extends DatastoreValue & { id: DatastoreKey },
|
||||
J, // Intermediate type returned from migrations
|
||||
> extends DatastoreProvider<K, V> {
|
||||
> extends DatastoreProvider<V["id"], V> {
|
||||
constructor(
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any,
|
||||
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as any,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Save the data to the json file
|
||||
async save(data: Map<K, V>) {
|
||||
async save(data: Map<V["id"], V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await safeOutputFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
async load() {
|
||||
async load(): Promise<Map<V["id"], V>> {
|
||||
// Read the raw json from the file
|
||||
let rawJson;
|
||||
try {
|
||||
@@ -137,7 +135,10 @@ export class JsonIdDatastoreProvider<
|
||||
export abstract class Datastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends EventEmitter {
|
||||
U = V,
|
||||
> extends EventEmitter<{
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
private dirty = false;
|
||||
|
||||
constructor(
|
||||
@@ -167,7 +168,7 @@ export abstract class Datastore<
|
||||
}
|
||||
|
||||
// Set the dirty flag and call update handlers
|
||||
protected touch(updates: any[]) {
|
||||
protected touch(updates: U[]) {
|
||||
if (updates.length) {
|
||||
this.dirty = true;
|
||||
this.emit("update", updates);
|
||||
@@ -208,13 +209,17 @@ export abstract class Datastore<
|
||||
valuesMutable() {
|
||||
return this.data.values();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.data.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// General key value mapping which can be saved and loaded with the file system
|
||||
export class KeyValueDatastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends Datastore<K, V> {
|
||||
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
|
||||
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
|
||||
set(key: K, value: V) {
|
||||
this.data.set(key, value);
|
||||
@@ -232,8 +237,10 @@ export class KeyValueDatastore<
|
||||
// Delete a value in the datastore
|
||||
delete(key: K) {
|
||||
const value = this.data.get(key);
|
||||
this.data.delete(key);
|
||||
this.touch([[key, value!, true]]);
|
||||
if (value !== undefined) {
|
||||
this.data.delete(key);
|
||||
this.touch([[key, value, true]]);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete many values at once from an array of keys
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data";
|
||||
|
||||
const ARCHIVE_URL = "https://factorio.com/download/archive";
|
||||
|
||||
/**
|
||||
* Represents the parsed result of a factorio version
|
||||
*/
|
||||
export const ExternalFactorioVersionSchema = Type.Object({
|
||||
stable: Type.Boolean(),
|
||||
version: FullVersionSchema,
|
||||
headlessUrl: Type.String(),
|
||||
});
|
||||
|
||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
|
||||
|
||||
/**
|
||||
* Fetch all factorio versions that support headless
|
||||
*
|
||||
* @returns A list of all factorio versions and their download URL
|
||||
* @throws Network errors and non-ok status responses
|
||||
*/
|
||||
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
|
||||
let res;
|
||||
|
||||
try {
|
||||
res = await fetch(ARCHIVE_URL);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
return parseFactorioVersions(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all versions from the factorio archive webpage
|
||||
*
|
||||
* @param html The HTML content of the archive page
|
||||
* @returns A list of all factorio versions and their download URL
|
||||
*/
|
||||
function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
|
||||
const versions = new Map<string, ExternalFactorioVersion>();
|
||||
|
||||
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
|
||||
// eslint-disable-next-line max-len
|
||||
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
|
||||
while ((match = anchorRegex.exec(html)) !== null) {
|
||||
const kind = match.groups?.kind;
|
||||
const version = match.groups?.version;
|
||||
|
||||
if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) {
|
||||
versions.set(version, {
|
||||
version: version,
|
||||
stable: kind === "stable",
|
||||
headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...versions.values()];
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Library for interfacing with external apis
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
@@ -3,6 +3,8 @@ import zlib from "zlib";
|
||||
class MapReaderState {
|
||||
pos = 0;
|
||||
last_position = { x: 0, y: 0 };
|
||||
/** True when a version greater than 2.0.0 is detected */
|
||||
v2 = false;
|
||||
constructor(
|
||||
public buf: Buffer
|
||||
) { }
|
||||
@@ -159,18 +161,49 @@ function readBoundingBox(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readCliffSettings(state: MapReaderState) {
|
||||
return {
|
||||
return state.v2 ? {
|
||||
name: readString(state),
|
||||
elevation_0: readFloat(state),
|
||||
elevation_interval: readFloat(state),
|
||||
control: readString(state), // v2
|
||||
cliff_elevation_0: readFloat(state),
|
||||
cliff_elevation_interval: readFloat(state),
|
||||
richness: readFloat(state),
|
||||
cliff_smoothing: readFloat(state), // v2
|
||||
} : {
|
||||
name: readString(state),
|
||||
cliff_elevation_0: readFloat(state),
|
||||
cliff_elevation_interval: readFloat(state),
|
||||
richness: readFloat(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readMapGenSettings(state: MapReaderState) {
|
||||
function readTerritorySettings(state: MapReaderState) {
|
||||
return {
|
||||
terrain_segmentation: readFloat(state),
|
||||
water: readFloat(state),
|
||||
units: readArray(state, readString),
|
||||
territory_index_expression: readString(state),
|
||||
territory_variation_expression: readString(state),
|
||||
minimum_territory_size: readUInt32(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readMapGenSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
|
||||
default_enable_all_autoplace_controls: readBool(state),
|
||||
seed: readUInt32(state),
|
||||
width: readUInt32(state),
|
||||
height: readUInt32(state),
|
||||
area_to_generate_at_start: readBoundingBox(state),
|
||||
starting_area: readFloat(state),
|
||||
peaceful_mode: readBool(state),
|
||||
no_enemies_mode: readBool(state), // v2
|
||||
starting_points: readArray(state, readMapPosition),
|
||||
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
|
||||
cliff_settings: readCliffSettings(state),
|
||||
territory_settings: readOptional(state, readTerritorySettings), // v2
|
||||
} : {
|
||||
terrain_segmentation: readFloat(state), // v1
|
||||
water: readFloat(state), // v1
|
||||
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
|
||||
default_enable_all_autoplace_controls: readBool(state),
|
||||
@@ -303,16 +336,36 @@ function readPathFinder(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readDifficultySettings(state: MapReaderState) {
|
||||
return {
|
||||
recipe_difficulty: readUInt8(state),
|
||||
technology_difficulty: readUInt8(state),
|
||||
return state.v2 ? {
|
||||
technology_price_multiplier: readDouble(state),
|
||||
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
|
||||
spoil_time_modifier: readDouble(state), // v2
|
||||
} : {
|
||||
recipe_difficulty: readUInt8(state), // v1
|
||||
technology_difficulty: readUInt8(state), // v1
|
||||
technology_price_multiplier: readDouble(state),
|
||||
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)], // v1
|
||||
};
|
||||
}
|
||||
|
||||
function readAsteroids(state: MapReaderState) {
|
||||
return {
|
||||
spawning_rate: readOptional(state, readDouble) ?? 1,
|
||||
max_ray_portals_expanded_per_tick: readOptional(state, readUInt32) ?? 100,
|
||||
};
|
||||
}
|
||||
|
||||
function readMapSettings(state: MapReaderState) {
|
||||
return {
|
||||
return state.v2 ? {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
} : {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
@@ -361,7 +414,7 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed map exchange string: zlib inflate failed");
|
||||
@@ -372,8 +425,10 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
let data: MapExchangeData;
|
||||
|
||||
try {
|
||||
const version = readVersion(state);
|
||||
state.v2 = version[0] >= 2;
|
||||
data = {
|
||||
version: readVersion(state),
|
||||
version: version,
|
||||
unknown: readUInt8(state),
|
||||
map_gen_settings: readMapGenSettings(state),
|
||||
map_settings: readMapSettings(state),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable node/global-require */
|
||||
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
// eslint-disable-next-line node/no-extraneous-require
|
||||
|
||||
module.exports = require("fast-deep-equal/es6");
|
||||
} else {
|
||||
module.exports = require("util").isDeepStrictEqual;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
module.exports = process.env.APP_ENV === "browser" ? window.WebSocket : require("ws");
|
||||
@@ -1,8 +1,8 @@
|
||||
// Connection adapters for Links
|
||||
import { strict as assert } from "assert";
|
||||
import events from "events";
|
||||
import WebSocket from "./WebSocket";
|
||||
|
||||
import WebSocket from "./WebSocket";
|
||||
import * as libData from "../data";
|
||||
import * as libErrors from "../errors";
|
||||
import { logger } from "../logging";
|
||||
@@ -23,6 +23,7 @@ export enum ConnectionClosed {
|
||||
ProtocolError = 1002, // Protocol not followed
|
||||
PolicyViolation = 1008, // Generic code for any endpoint policy
|
||||
InternalError = 1011, // Endpoint failed to fulfil request
|
||||
TryAgainLater = 1013, // Temporary server condition blocking requests
|
||||
|
||||
// Codes after 3000 are available for frameworks
|
||||
// However they should be registered to IANA
|
||||
@@ -32,6 +33,14 @@ export enum ConnectionClosed {
|
||||
|
||||
// Codes after 4000 are available for applications
|
||||
MalformedMessage = 4000,
|
||||
RecoveryMode = 4001,
|
||||
};
|
||||
|
||||
type BaseConnectorEvents = {
|
||||
"message": [ message: libData.Message ],
|
||||
"error": [ err: any ],
|
||||
"invalidate": [],
|
||||
"reset": [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,7 +48,9 @@ export enum ConnectionClosed {
|
||||
*
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export abstract class BaseConnector extends events.EventEmitter {
|
||||
export abstract class BaseConnector<
|
||||
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
|
||||
> extends events.EventEmitter<E | BaseConnectorEvents> {
|
||||
protected _seq = 1;
|
||||
|
||||
constructor(
|
||||
@@ -51,10 +62,12 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
|
||||
_reset() {
|
||||
this._seq = 1;
|
||||
this.emit("reset");
|
||||
}
|
||||
|
||||
_invalidate() {
|
||||
this._seq = 1;
|
||||
this.emit("invalidate");
|
||||
}
|
||||
|
||||
protected abstract send(message: libData.MessageRoutable): void;
|
||||
@@ -129,6 +142,15 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
|
||||
type ConnectorState = "closed" | "connecting" | "connected" | "resuming";
|
||||
|
||||
type WebSocketBaseConnectorEvents = {
|
||||
"disconnectReady": [],
|
||||
"disconnectPrepare": [],
|
||||
"connect": [ data: any ],
|
||||
"resume": [],
|
||||
"close": [],
|
||||
"drop": [],
|
||||
};
|
||||
|
||||
export type WebSocketClusterio = WebSocket.WebSocket & {
|
||||
clusterio_ignore_dump?: boolean;
|
||||
};
|
||||
@@ -138,7 +160,9 @@ export type WebSocketClusterio = WebSocket.WebSocket & {
|
||||
*
|
||||
* @extends module:lib.BaseConnector
|
||||
*/
|
||||
export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
export abstract class WebSocketBaseConnector<
|
||||
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
|
||||
> extends BaseConnector<E | WebSocketBaseConnectorEvents> {
|
||||
// One of closed, connecting (client only), connected and resuming.
|
||||
_state: ConnectorState = "closed";
|
||||
_closing = false;
|
||||
@@ -164,7 +188,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
_invalidate() {
|
||||
this._state = "connecting";
|
||||
assert(this._closing === false);
|
||||
assert(this._socket);
|
||||
// We do not care about the socket state
|
||||
this._lastHeartbeatMs = null;
|
||||
assert(this._heartbeatId === null);
|
||||
this._heartbeatInterval = null;
|
||||
@@ -173,6 +197,11 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
super._invalidate();
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
_check(...expectedStates: ConnectorState[]) {
|
||||
if (!expectedStates.includes(this._state)) {
|
||||
throw new Error(
|
||||
@@ -242,7 +271,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
this._check("connected");
|
||||
if (Date.now() - this._lastHeartbeatMs! > 2000 * this._heartbeatInterval!) {
|
||||
logger.verbose("Connector | closing after heartbeat timed out");
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket!.close(ConnectionClosed.Timeout, "Heartbeat timeout");
|
||||
} else {
|
||||
@@ -383,12 +412,16 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
}
|
||||
}
|
||||
|
||||
type WebSocketClientConnectorEvents = {
|
||||
"hello": [ data: any ],
|
||||
};
|
||||
|
||||
/**
|
||||
* Connector for controller clients
|
||||
*
|
||||
* @extends module:lib.WebSocketBaseConnector
|
||||
*/
|
||||
export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
export abstract class WebSocketClientConnector extends WebSocketBaseConnector<WebSocketClientConnectorEvents> {
|
||||
_reconnectId?: ReturnType<typeof setTimeout>;
|
||||
_sessionToken: string | null = null;
|
||||
_sessionTimeout: number | null = null;
|
||||
@@ -416,8 +449,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResumingMs = null;
|
||||
super._reset();
|
||||
this.src = undefined as any;
|
||||
super._reset();
|
||||
}
|
||||
|
||||
_invalidate() {
|
||||
@@ -425,8 +458,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResumingMs = null;
|
||||
super._invalidate();
|
||||
this.src = undefined as any;
|
||||
super._invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,8 +494,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
await events.once(this, "close");
|
||||
|
||||
} else {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,8 +519,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._socket!.close(ConnectionClosed.NormalClosure, "Connector closing");
|
||||
|
||||
} else {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +544,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
// Open WebSocket to controller
|
||||
logger.verbose(`Connector | connecting to ${url}`);
|
||||
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket = new WebSocket(url) as WebSocketClusterio;
|
||||
|
||||
@@ -544,13 +575,10 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
&& this._startedResumingMs! + this._sessionTimeout! * 1000 < Date.now() + delayMs
|
||||
) {
|
||||
logger.error("Connector | Session timed out trying to resume");
|
||||
this._reset();
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
} else {
|
||||
this._state = "connecting";
|
||||
this.emit("invalidate");
|
||||
this._invalidate();
|
||||
}
|
||||
}
|
||||
logger.verbose(
|
||||
@@ -602,8 +630,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
} else {
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
|
||||
} else {
|
||||
this.reconnect();
|
||||
@@ -709,11 +736,9 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
} else if (type === "invalidate") {
|
||||
logger.warn("Connector | session invalidated by controller");
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
} else {
|
||||
this._invalidate();
|
||||
this.emit("invalidate");
|
||||
this.register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class Link {
|
||||
// Process messages received by the connector
|
||||
connector.on("message", payload => {
|
||||
try {
|
||||
this._processMessage(payload);
|
||||
this._processMessage(payload as MessageRoutable);
|
||||
} catch (err) {
|
||||
if (err instanceof libErrors.InvalidMessage) {
|
||||
logger.error(err.message);
|
||||
@@ -128,10 +128,10 @@ export class Link {
|
||||
}
|
||||
});
|
||||
|
||||
// The events below exist only on websocket connectors, we can't early return because of test mocking
|
||||
const webSocketConnector = this.connector as WebSocketBaseConnector;
|
||||
connector.on("disconnectPrepare", () => {
|
||||
this.prepareDisconnect().finally(() => {
|
||||
// Only WebSocket connectors issue disconnection events.
|
||||
const webSocketConnector = this.connector as WebSocketBaseConnector;
|
||||
if (webSocketConnector.hasSession) {
|
||||
webSocketConnector.sendDisconnectReady();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as host from "../data/messages_host";
|
||||
import * as instance from "../data/messages_instance";
|
||||
import * as mod from "../data/messages_mod";
|
||||
import * as user from "../data/messages_user";
|
||||
import * as role from "../data/messages_role";
|
||||
import * as plugin from "../data/messages_plugin";
|
||||
import * as subscriptions from "../subscriptions";
|
||||
import type { RequestClass, EventClass } from "./link";
|
||||
|
||||
@@ -19,6 +21,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
|
||||
controller.ControllerStopRequest,
|
||||
controller.ControllerRestartRequest,
|
||||
controller.ControllerUpdateRequest,
|
||||
controller.ControllerConfigGetRequest,
|
||||
controller.ControllerConfigSetFieldRequest,
|
||||
controller.ControllerConfigSetPropRequest,
|
||||
@@ -32,11 +35,13 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.SystemInfoUpdateEvent,
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
host.HostStopRequest,
|
||||
host.HostRestartRequest,
|
||||
host.HostUpdateRequest,
|
||||
host.HostConfigGetRequest,
|
||||
host.HostConfigSetFieldRequest,
|
||||
host.HostConfigSetPropRequest,
|
||||
@@ -59,6 +64,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
instance.InstanceRestartRequest,
|
||||
instance.InstanceSaveDetailsListRequest,
|
||||
instance.InstanceSaveDetailsUpdatesEvent,
|
||||
instance.InstanceCreateSaveRequest,
|
||||
@@ -97,17 +103,22 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
mod.ModGetRequest,
|
||||
mod.ModListRequest,
|
||||
mod.ModSearchRequest,
|
||||
mod.ModPortalGetAllRequest,
|
||||
mod.ModDownloadRequest,
|
||||
mod.ModDeleteRequest,
|
||||
mod.ModPackUpdatesEvent,
|
||||
mod.ModUpdatesEvent,
|
||||
mod.ModPortalDownloadRequest,
|
||||
mod.ModDependencyResolveRequest,
|
||||
|
||||
role.PermissionListRequest,
|
||||
role.RoleListRequest,
|
||||
role.RoleUpdatesEvent,
|
||||
role.RoleCreateRequest,
|
||||
role.RoleUpdateRequest,
|
||||
role.RoleGrantDefaultPermissionsRequest,
|
||||
role.RoleDeleteRequest,
|
||||
|
||||
user.PermissionListRequest,
|
||||
user.RoleListRequest,
|
||||
user.RoleCreateRequest,
|
||||
user.RoleUpdateRequest,
|
||||
user.RoleGrantDefaultPermissionsRequest,
|
||||
user.RoleDeleteRequest,
|
||||
user.UserGetRequest,
|
||||
user.UserListRequest,
|
||||
user.UserCreateRequest,
|
||||
@@ -118,4 +129,10 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
user.UserSetBannedRequest,
|
||||
user.UserDeleteRequest,
|
||||
user.UserUpdatesEvent,
|
||||
user.UserBulkImportRequest,
|
||||
user.UserBulkExportRequest,
|
||||
|
||||
plugin.PluginListRequest,
|
||||
plugin.PluginUpdateRequest,
|
||||
plugin.PluginInstallRequest,
|
||||
];
|
||||
@@ -519,7 +519,7 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
|
||||
}
|
||||
|
||||
export function handleUnhandledErrors() {
|
||||
/* eslint-disable node/no-process-exit */
|
||||
|
||||
process.on("uncaughtException", err => {
|
||||
logger.fatal(`Uncaught exception:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
@@ -528,7 +528,7 @@ export function handleUnhandledErrors() {
|
||||
logger.fatal(`Unhandled rejection:\n${reason.stack ? reason.stack : reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
/* eslint-enable node/no-process-exit */
|
||||
|
||||
}
|
||||
|
||||
// for testing only
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* @module lib/users
|
||||
*/
|
||||
import { Permission, Role } from "./data";
|
||||
import { SubscribableDatastore } from "./datastore";
|
||||
|
||||
|
||||
export const permissions = new Map<string, Permission>();
|
||||
@@ -66,7 +67,7 @@ export function definePermission({
|
||||
* @param roles - role storage to modify.
|
||||
* @returns the default admin role
|
||||
*/
|
||||
export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
export function ensureDefaultAdminRole(roles: SubscribableDatastore<Role>) {
|
||||
let admin = roles.get(0);
|
||||
if (!admin) {
|
||||
admin = Role.fromJSON({
|
||||
@@ -75,9 +76,9 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
description: "Cluster wide administrator.",
|
||||
permissions: [],
|
||||
});
|
||||
roles.set(0, admin);
|
||||
}
|
||||
admin.permissions.add("core.admin");
|
||||
roles.set(admin);
|
||||
return admin;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
*
|
||||
* @param roles - role storage to modify.
|
||||
*/
|
||||
export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
|
||||
export function ensureDefaultPlayerRole(roles: SubscribableDatastore<Role>) {
|
||||
let player = roles.get(1);
|
||||
if (!player) {
|
||||
player = Role.fromJSON({
|
||||
@@ -99,9 +100,9 @@ export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
|
||||
description: "Default player role.",
|
||||
permissions: [],
|
||||
});
|
||||
roles.set(1, player);
|
||||
}
|
||||
player.grantDefaultPermissions();
|
||||
roles.set(player);
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +132,11 @@ definePermission({
|
||||
title: "Restart controller",
|
||||
description: "Restart the Node.js controller process if the system is set up for restarting.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.controller.update",
|
||||
title: "Update controller",
|
||||
description: "Remotely update the controller if the controller allows for remote updates.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.controller.get_config",
|
||||
title: "Get controller config",
|
||||
@@ -160,6 +166,11 @@ definePermission({
|
||||
title: "Restart Hosts",
|
||||
description: "Restart Node.js host processes if the system they run on are set up for restarting.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.host.update",
|
||||
title: "Update Hosts",
|
||||
description: "Remotely update a host if the host allows for remote updates.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.host.get_config",
|
||||
title: "Get host configs",
|
||||
@@ -296,6 +307,11 @@ definePermission({
|
||||
title: "Start instance",
|
||||
description: "Start instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.restart",
|
||||
title: "Restart instance",
|
||||
description: "Restart instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.load_scenario",
|
||||
title: "Load scenario",
|
||||
@@ -373,6 +389,12 @@ definePermission({
|
||||
description: "Search through the list of mods stored on the controller.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.search_portal",
|
||||
title: "Search mod portal",
|
||||
description: "Search for mods on the Factorio mod portal.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.subscribe",
|
||||
title: "Subscribe to mod updates",
|
||||
@@ -381,7 +403,7 @@ definePermission({
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.upload",
|
||||
title: "Upload mod",
|
||||
title: "Upload mods",
|
||||
description: "Upload mods to the controller.",
|
||||
});
|
||||
definePermission({
|
||||
@@ -395,6 +417,12 @@ definePermission({
|
||||
title: "Delete mod",
|
||||
description: "Delete mods stored on the controller.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.download_from_portal",
|
||||
title: "Download mods from portal",
|
||||
description: "Allow downloading mods directly from the Factorio mod portal to the controller.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.permission.list",
|
||||
@@ -409,6 +437,12 @@ definePermission({
|
||||
description: "Get the full list of roles and their permissions in the cluster.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.role.subscribe",
|
||||
title: "Subscribe to role updates",
|
||||
description: "Subscribe to be notified on updates on the details of roles.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.role.create",
|
||||
title: "Create role",
|
||||
@@ -484,6 +518,24 @@ definePermission({
|
||||
title: "Delete user",
|
||||
description: "Delete users and all data stored for them.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.user.bulk_import",
|
||||
title: "Bulk user import",
|
||||
description: "Bulk import users including admin, whitelist, and bans." +
|
||||
" (Imports types are restricted by other permissions, e.g. core.user.set_admin)",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.user.bulk_export",
|
||||
title: "Bulk user export",
|
||||
description: "Bulk export users including admin, whitelist, and bans.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.user.bulk_restore",
|
||||
title: "Bulk user restore",
|
||||
description: "Bulk restore users including admin, whitelist, and bans." +
|
||||
" (Restore types are restricted by other permissions, e.g. core.user.set_admin)",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.log.follow",
|
||||
title: "Follow cluster log",
|
||||
@@ -495,8 +547,30 @@ definePermission({
|
||||
description: "Query past entries in the cluster log. Required to see past entries in instance console.",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.plugin.list",
|
||||
title: "List plugins",
|
||||
description: "List all installed plugins on a machine.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.plugin.update",
|
||||
title: "Update plugin",
|
||||
description: "Remotely update a plugin if the target allows remote updates of plugins.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.plugin.install",
|
||||
title: "Install plugin",
|
||||
description: "Remotely install a plugin if the target allows remote installs of plugins.",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.debug.dump_ws",
|
||||
title: "Dump WebSocket",
|
||||
description: "Dump all WebSocket communicatation from the controller.",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.external.get_factorio_versions",
|
||||
title: "Get factorio versions",
|
||||
description: "Get list of all factorio versions from Wube",
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
/**
|
||||
* Helpers for loading plugins in Node.js
|
||||
* @module lib/plugin_loader
|
||||
*/
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import * as libErrors from "./errors";
|
||||
import * as libPlugin from "./plugin";
|
||||
import { logger } from "./logging";
|
||||
@@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
// Check if plugin path exists, otherwise remove it
|
||||
try {
|
||||
require.resolve(pluginPath);
|
||||
} catch (err) {
|
||||
logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`);
|
||||
} catch {
|
||||
let errMsg = `Plugin path ${pluginPath} does not exist`;
|
||||
try {
|
||||
await fs.access(pluginPath, fs.constants.F_OK);
|
||||
errMsg = `Plugin path ${pluginPath} missing index or main file`;
|
||||
} catch {}
|
||||
logger.error(`${errMsg}, not loading ${pluginName}`);
|
||||
pluginList.delete(pluginName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
|
||||
if (key !== "") {
|
||||
for (let pair of key.split(",")) {
|
||||
let [name, value] = pair.split("=", 2);
|
||||
if (!name || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
labels.set(name, value
|
||||
.slice(1, -1)
|
||||
.replace(/\\"/g, "\"")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import util from "util";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { logger } from "./logging";
|
||||
import { RequestError } from "./errors";
|
||||
import { PluginNodeEnvInfo } from "./plugin";
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
function isDev() {
|
||||
// dev: <devRoot>/packages/lib/dist/src/rce_ops.js
|
||||
// prod: <prodRoot>/node_modules/@clusterio/lib/dist/src/rce_ops.js
|
||||
return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
|
||||
}
|
||||
|
||||
async function logExec(cmd: string) {
|
||||
logger.audit(`RCE | ${cmd}`);
|
||||
if (!isDev()) {
|
||||
await execAsync(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePackage(name: string) {
|
||||
return logExec(`npm update --save ${name}`);
|
||||
}
|
||||
|
||||
export async function installPackage(name: string) {
|
||||
return logExec(`npm install --save ${name}`);
|
||||
}
|
||||
|
||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
|
||||
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
|
||||
}
|
||||
|
||||
return await updatePackage(pluginName);
|
||||
}
|
||||
|
||||
export async function handlePluginInstall(pluginName: string) {
|
||||
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
|
||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
||||
// https://www.npmjs.com/package/validate-npm-package-name
|
||||
throw new RequestError(`Invalid plugin name: ${pluginName}`);
|
||||
}
|
||||
|
||||
const packageName = encodeURI(pluginName);
|
||||
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (!npmRequest.ok) {
|
||||
throw new RequestError(`Unknown plugin: ${packageName}`);
|
||||
}
|
||||
|
||||
return await installPackage(packageName);
|
||||
}
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user