Compare commits

..
Author SHA1 Message Date
Danielv123 3d97b69b07 Add Windows headless installer support 2026-03-11 23:25:02 +01:00
Danielv123 5b4621a7de Add Windows headless update downloads 2026-03-11 23:25:02 +01:00
443 changed files with 9633 additions and 25624 deletions

No files matched your search

-48
View File
@@ -1,48 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
"runtime": {
"version": "Lua5.2",
"requirePattern": [ "?", "?.lua" ]
},
"workspace": {
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
"ignoreGlobs": [
"**/node_modules/**",
"**/dist/**",
"database/**",
"docs/**",
"external_plugins/**",
"factorio/**",
"instances/**",
"logs/**",
"mods/**",
"modules/**",
"temp/**",
"packages/create/templates/**"
],
"moduleMap": [
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
]
},
"doc": {
"privateName": [ "__*" ]
},
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
"diagnostics": {
"globals": [ "__DebugAdapter", "__Profiler" ],
"disable": [
"assign-type-mismatch",
"param-type-mismatch",
"preferred-local-alias",
"unnecessary-assert",
"unnecessary-if",
"unused"
]
}
}
-53
View File
@@ -1,53 +0,0 @@
name: Install dependencies
description: Set up pnpm and Node.js, restore caches and run pnpm install
inputs:
node-version:
description: Node.js version passed to actions/setup-node
required: true
ignore-scripts:
description: Skip the prepare scripts that build the workspace packages
default: "false"
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
# The lockfile is not committed, so the usual lockfile hash key does not
# apply. Instead a new store snapshot is saved whenever a package.json
# changes or a week passes, and every run restores the most recent one.
- id: store
shell: bash
run: |
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: ${{ steps.store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-
# The prepare scripts run tsc --build and webpack, both incremental when
# their previous output is present. Every run saves its output and the
# next run starts from the most recent one.
- if: inputs.ignore-scripts != 'true'
uses: actions/cache@v6
with:
path: |
packages/*/dist
plugins/*/dist
packages/*/node_modules/.cache
plugins/*/node_modules/.cache
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
- shell: bash
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
# Drop packages the restored store had but this install did not use, so
# the snapshot does not grow with every version bump.
- shell: bash
run: pnpm store prune
+5 -10
View File
@@ -1,15 +1,10 @@
<!--
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
-->
### Changelog
## Changelog
```
### Features
- Added doodad to web interface. #999
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
### Changes
- ...
@@ -17,6 +12,6 @@ If there are no relevant changelog entries replace the code block below with "No
### Fixes
- ...
### Breaking Changes
### Breaking changes
- ...
```
-83
View File
@@ -1,83 +0,0 @@
name: Lua Lint
on:
push:
pull_request:
env:
EMMYLUA_CHECK_VERSION: "0.25.1"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Generate Factorio typedefs
run: |
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
- name: Install emmylua_check
run: |
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP"
- name: Run Lint Report
shell: bash
run: |
# The typedefs are generated per run, so their path is passed via a second config
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
'{ workspace: { library: [ $lib ] } }' \
> "$RUNNER_TEMP/ci.emmyrc.json"
# Exits non zero on error severity, so the gate below is used instead
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
--output-format json --output check.json || true
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
# Although some minor fixes were needed
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
code: .code,
line: .range.start.line,
message: (.message | split("\n")
| map(if startswith("- ") then .[2:] else . end)
| join("\n")),
severity: (if .severity <= 1 then "ERR" else "WARN" end)
} |
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
check.json > "$GITHUB_STEP_SUMMARY"
echo ""
# Github Annotations (limited to 10)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
title: .code,
line: .range.start.line,
endLine: .range.end.line,
col: .range.start.character,
endColumn: .range.end.character,
message: (.message | gsub("\n"; "%0A"; "m")),
level: (if .severity <= 1 then "error" else "warning" end)
} |
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
check.json
# An entry is emitted for every file checked, including clean ones, so
# the gate counts diagnostics and asserts the workspace was actually read
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
exit 1
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
exit 1
else
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
fi
+27 -69
View File
@@ -7,112 +7,70 @@ on:
push:
pull_request:
env:
NODE_VERSION: 24.x
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
# Node.js release schedule: https://nodejs.org/en/about/releases/
node-version: [24.x, 26.x]
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.77, 2.1.17]
# Coverage is performed on the older of the supported node versions
include:
- node-version: 22.x
factorio-version: 2.1.17
coverage: true
# 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@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Prepare and install
run: pnpm i --no-frozen-lockfile
- name: Use Factorio ${{ matrix.factorio-version }}
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ !matrix.coverage }}
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.coverage }}
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
run: pnpm run ci-cover
- name: Upload coverage to Codecov
if: ${{ matrix.coverage }}
uses: codecov/codecov-action@v7
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
version-compat:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# npm version spec for the published side of the compatibility test.
# If each release is compatible with the one before it then testing
# against the latest release transitively covers older versions too.
PUBLISHED_VERSION: latest
strategy:
fail-fast: false
matrix:
include:
- controller: local
host: published
- controller: published
host: local
steps:
- uses: actions/checkout@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build
run: pnpm run build
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
run: >
node scripts/version-compat-test.js
--controller ${{ matrix.controller }}
--host ${{ matrix.host }}
--published-version "$PUBLISHED_VERSION"
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
- name: Upload build to artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: clusterio_lib
path: ./dist
+14 -16
View File
@@ -6,24 +6,22 @@ on:
types:
- released
permissions:
id-token: write
contents: write
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
- uses: softprops/action-gh-release@v3
- 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
@@ -31,16 +29,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match run build-mod --output-dir ../../dist
- 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@v3
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+2 -2
View File
@@ -10,8 +10,9 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directories used by tools like c8 and codecov
# Coverage directories used by tools like nyc and codecov
coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
@@ -37,7 +38,6 @@ node_modules
/config-*.json.lock
pnpm-lock.yaml
mod_cache.json
.luarc.json
/static/
saves
/temp/
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+10 -312
View File
@@ -31,308 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.2
### Fixes
- Fixed installation with selected plugins by relying on automatic plugin discovery, and ensured installer failures return a nonzero exit status. [#1082](<https://github.com/clusterio/clusterio/pull/1082>), [#1084](<https://github.com/clusterio/clusterio/issues/1084>)
- Fixed web plugin `controllerConnectionEvent` handlers receiving the connector's event data instead of the event name. [#1083](<https://github.com/clusterio/clusterio/issues/1083>)
- Updated installation instructions to recommend latest Node.js LTS and corrected outdated mod management, CLI, plugin, and release documentation. [#1086](<https://github.com/clusterio/clusterio/pull/1086>)
### Meta
- Made player-auth login tests independent of wall-clock timing and added verification-token expiry coverage. [#1087](<https://github.com/clusterio/clusterio/pull/1087>)
- Added Node.js 26 to the CI test matrix across all tested Factorio versions. [#1088](<https://github.com/clusterio/clusterio/pull/1088>)
Many thanks to the following for contributing to this release:
[@psihius](<https://github.com/psihius>), [@bbassie](<https://github.com/bbassie>)
## Version 2.0.1
### Fixes
- Removed obsolete development-branch warnings from controller, host and ctl startup. [#1078](<https://github.com/clusterio/clusterio/pull/1078>)
- Removed the obsolete alpha label and development warning from the Clusterio Library mod title and description. [#1079](<https://github.com/clusterio/clusterio/pull/1079>)
- Fixed Factorio mod release uploads to use valid multipart requests and files from the requested output directory, uploading variants in version order. [#1080](<https://github.com/clusterio/clusterio/pull/1080>)
Many thanks to the following for contributing to this release:
[@psihius](<https://github.com/psihius>)
## Version 2.0.0
### Features
- Mod packs now show informational notices for disabled recommended mods and available compatible updates. [#949](<https://github.com/clusterio/clusterio/issues/949>)
- Added host, server name, save copy and start options to the create instance dialog in the web UI. [#405](<https://github.com/clusterio/clusterio/issues/405>)
- Added `InstanceSaveGameRequest` and `clusterioctl instance save-game` to save the running game of an instance. [#405](<https://github.com/clusterio/clusterio/issues/405>)
- Added `permissions` to `PluginDeclaration` and a `Permissions` type registry so plugin permission names are checked at compile time. [#987](<https://github.com/clusterio/clusterio/issues/987>)
- Added Update All to the web interface and `--all` to the ctl update commands, updating the core package and all npm installed plugins in one npm call. [#910](<https://github.com/clusterio/clusterio/issues/910>)
- Added typed hook system for plugin registration. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- Added `plugin install <package>` command to controller, host and ctl which installs a plugin from npm and adds it to the plugin list. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
- Added a guard that prevents controller and host restart requests from unintentionally downgrading Clusterio. [#950](<https://github.com/clusterio/clusterio/issues/950>)
- Added option to load static assets from a separate URL from where the controller's web API is hosted at. [#983](<https://github.com/clusterio/clusterio/pull/983>)
- Added `clusteriocontroller copy-static` command to copy static web assets to a folder to ease hosting assets on a separate server. [#983](<https://github.com/clusterio/clusterio/pull/983>)
- Added Mark Restart Required to the host and controller pages and matching ctl commands, for plugins installed by other means than the install request. [#1043](<https://github.com/clusterio/clusterio/pull/1043>)
- Added the RoleViewPageBeforePermissions web extension slot for plugin components above the permissions on the role page. [#1045](<https://github.com/clusterio/clusterio/pull/1045>)
- Added a list of permission groups to the role page, highlighting groups with granted permissions. [#1044](<https://github.com/clusterio/clusterio/pull/1044>)
- Added `clusteriohost create-scenario` to create a scenario with the Clusterio modules patched in for use outside of Clusterio. [#1014](<https://github.com/clusterio/clusterio/pull/1014>)
- Plugins can declare a `migrationEntrypoint` to migrate the config fields and role permissions core stores for them. Configs track the plugin version they were last loaded with in a hidden `<plugin>.version` field. [#1073](<https://github.com/clusterio/clusterio/pull/1073>)
### Fixes
- Deprecation warning DEP0190 is no longer printed when installing. [#851](<https://github.com/clusterio/clusterio/issues/851>)
- Events sent to subscribers are no longer addressed to the request id of the request that created the subscription. [#847](<https://github.com/clusterio/clusterio/issues/847>)
- Factorio account token no longer written to the log when a mod download fails. [#795](<https://github.com/clusterio/clusterio/issues/795>)
- Failed downloads no longer leave a temporary file and an open file handle behind, which could shift the file names used by later downloads. [#957](<https://github.com/clusterio/clusterio/pull/957>)
- Requests that could not be forwarded to the next hop are no longer answered a second time when the link disconnects. [#650](<https://github.com/clusterio/clusterio/issues/650>)
- Events broadcast from an instance to all hosts are now delivered to every host instead of only the one running the instance. [#575](<https://github.com/clusterio/clusterio/issues/575>)
- Web interface now asks to be reloaded when the controller's plugins change, instead of continuing against plugins that are no longer loaded. [#804](<https://github.com/clusterio/clusterio/issues/804>)
- Plugin list no longer reports a plugin added since the page was opened as having failed to load. [#729](<https://github.com/clusterio/clusterio/issues/729>)
- Instance console no longer hides the Factorio server's replies to console commands, or Factorio's own errors, when filtering to game activity. [#853](<https://github.com/clusterio/clusterio/issues/853>)
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980](<https://github.com/clusterio/clusterio/issues/980>)
- Fixed inventory_sync not preserving the personal roboport toggle, the equipment movement bonus toggle, game view settings such as alt mode, and toggleable mod shortcuts between instances. [#898](<https://github.com/clusterio/clusterio/issues/898>)
- Schema validation errors are now included when an InvalidMessage is logged from IPC and event handlers. [#986](<https://github.com/clusterio/clusterio/issues/986>)
- Fixed clusterioctl not being able to reference hosts, instances, mod packs and roles by an integer like name. [#886](<https://github.com/clusterio/clusterio/issues/886>)
- Fixed the web-only plugin template referencing a permission that was never defined. [#987](<https://github.com/clusterio/clusterio/issues/987>)
- Config files are no longer world readable by default. [#1005](<https://github.com/clusterio/clusterio/issues/1005>)
- Fixed failed recipe notification names being duplicated on every upload from the same instance in inventory sync. [#964](<https://github.com/clusterio/clusterio/issues/964>)
- Instances refuse to start with a mod pack for a newer Factorio version than the instance runs, instead of Factorio silently loading default mod settings. [#1006](<https://github.com/clusterio/clusterio/issues/1006>)
- Documented the config options missing from configuration.md, marked the internal ones and noted the validation rules. [#985](<https://github.com/clusterio/clusterio/issues/985>)
- Fixed concurrent saves of the same file racing on a shared temporary file. [#1026](<https://github.com/clusterio/clusterio/pull/1026>)
- Fixed control connections leaving their log subscription and debug dump listeners attached after closing. [#1027](<https://github.com/clusterio/clusterio/pull/1027>)
- Fixed the Load Scenario modal reading the wrong field for map settings. [#1032](<https://github.com/clusterio/clusterio/pull/1032>)
- Fixed the controller exiting when a control message's permission check threw an unexpected error. [#1033](<https://github.com/clusterio/clusterio/pull/1033>)
- Fixed the host exiting on server log lines that look like actions but do not match the expected format. [#1034](<https://github.com/clusterio/clusterio/pull/1034>)
- Fixed the controller crashing when a control connection with the WebSocket debug dump active was resumed. [#1035](<https://github.com/clusterio/clusterio/pull/1035>)
- Fixed deleted users keeping their permissions on any open connection until they disconnected. [#1030](<https://github.com/clusterio/clusterio/pull/1030>)
- Restored pagination on the instance save list. [#967](<https://github.com/clusterio/clusterio/issues/967>)
- Mod dependency resolution now only picks releases made for the mod pack's Factorio version. [#965](<https://github.com/clusterio/clusterio/issues/965>)
- Inventory sync no longer uploads an empty inventory over the stored one after a failed download. [#912](<https://github.com/clusterio/clusterio/issues/912>)
- Inventory sync now releases players who leave while using a temporary inventory, instead of showing them as busy until the instance stops. [#975](<https://github.com/clusterio/clusterio/pull/975>)
- Fixed reading map exchange strings from Factorio 2.1.13 and later, which broke `instance load-scenario --map-exchange-string` on current Factorio. [#977](<https://github.com/clusterio/clusterio/pull/977>)
- Fixed instances getting stuck on a host after a failed assign, and instance create requests no longer accept `instance.assigned_host`. [#1029](<https://github.com/clusterio/clusterio/pull/1029>)
- Fixed the host exiting when an instance's whitelist or server settings config changed while it was starting, or when tags contained non-string values. [#1028](<https://github.com/clusterio/clusterio/pull/1028>)
- Fixed a host being able to modify the controller's stored save list for, or forge log messages attributed to, instances assigned to other hosts. [#1036](<https://github.com/clusterio/clusterio/pull/1036>)
- Fixed a stop request or RCON command hanging when the Factorio process exited before its RCON connection was established. [#1031](<https://github.com/clusterio/clusterio/pull/1031>)
- Fixed core plugins needlessly installing the web_ui, controller, host and ctl packages. [#1021](<https://github.com/clusterio/clusterio/pull/1021>)
- Installing a plugin on a host or the controller now marks it as needing a restart. [#1042](<https://github.com/clusterio/clusterio/pull/1042>)
- Fixed plugin manifest path resolution when loading web assets. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
- A plugin whose web build is expected but missing is reported again: `Controller.loadJsonObject(path, true)` throws on a missing file instead of returning `{}`, and a malformed JSON file always throws. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
- Preserve full configuration field names when using the CLI config editor. [#796](<https://github.com/clusterio/clusterio/issues/796>)
- Plugin metrics hook handlers may return nothing. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
- Fixed save names given when starting an instance or creating a save not being checked, allowing files outside the instance directory to be written or modified. [#1053](<https://github.com/clusterio/clusterio/pull/1053>)
- Fixed access tokens from request headers being written to the controller and cluster logs. [#1054](<https://github.com/clusterio/clusterio/pull/1054>)
- Fixed subscriptions and log following continuing after the user lost permission for them. [#1056](<https://github.com/clusterio/clusterio/pull/1056>)
- Fixed web interface data staying marked as up to date after the user lost permission to view it, and not loading after permission was granted. [#1058](<https://github.com/clusterio/clusterio/pull/1058>)
- Fixed ctl config edit showing fields that cannot be edited and leaving out field descriptions. [#1051](<https://github.com/clusterio/clusterio/issues/1051>)
- Fixed the reason an instance failed to start being logged as "undefined". [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
- Fixed subspace inventory combinators missing items after instance startup. [#1069](<https://github.com/clusterio/clusterio/issues/1069>)
- Fixed the controller crashing when a WebSocket client sent an invalid frame before completing the handshake. [#1063](<https://github.com/clusterio/clusterio/issues/1063>)
- Fixed an instance reusing a stored game port that another process holds, which made it fail to start until the port was freed. [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
- Fixed credentials in request query parameters, such as OAuth codes, being written to the controller's HTTP log. [#1074](<https://github.com/clusterio/clusterio/pull/1074>)
- Fixed plugin `ctlConfigFields` not being registered in the clusterioctl config. [#1075](<https://github.com/clusterio/clusterio/pull/1075>)
- Fixed startup failures and plugin loading when installing Clusterio from published npm packages after the ES module migration. [#1070](<https://github.com/clusterio/clusterio/pull/1070>)
### Changes
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
- Instance console filter is now a labelled Game/Instance control instead of a single-label toggle, and Factorio's output in the log consoles is prefixed with `[server]` like every other log level. [#853](<https://github.com/clusterio/clusterio/issues/853>)
- Inventory sync sends only the recipe notifications that differ from the destination instance's state on download. [#964](<https://github.com/clusterio/clusterio/issues/964>)
- Log the source of responses received without a pending request, the peer of timed out connections and the number of messages resent on session resume. [#1007](<https://github.com/clusterio/clusterio/issues/1007>)
- Plugins that are not expected to have a web build are no longer shown with an error in the plugins page. [#1004](<https://github.com/clusterio/clusterio/issues/1004>)
- Plugins should now return a function to register handlers via hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- Plugin templates from `@clusterio/create` use entrypoint functions and hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- Improved typing for plugin APIs and webui extension points. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- Plugin hooks run concurrently instead of one plugin at a time. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- Access tokens are also accepted as an `Authorization: Bearer` header on HTTP API endpoints. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
- `plugin install` and `plugin add` with a package name now check that they are run from a Clusterio installation directory. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
- Bumped pnpm to 12, the GitHub Actions used by CI to their current majors, and the CI Factorio version to 2.1.17. [#1022](<https://github.com/clusterio/clusterio/pull/1022>)
- Bumped dependencies, including eslint 10, yargs 18, chalk 6, @types/express 5 and @types/node 24. [#1023](<https://github.com/clusterio/clusterio/pull/1023>)
- Plugin discovery now traverses dependencies only after confirming the parent package is a Clusterio plugin. [#963](<https://github.com/clusterio/clusterio/pull/963>)
- Converted the project to using ES Modules. [#984](<https://github.com/clusterio/clusterio/pull/984>)
- Converted the bundled plugins to entrypoint functions. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
- Added `controller.max_subscription_filters` config to limit the number of filters a connection can subscribe with for one event. [#1055](<https://github.com/clusterio/clusterio/pull/1055>)
- Added `controller.max_send_buffer_size` config to limit how much the controller buffers for a connection that does not acknowledge messages. [#1057](<https://github.com/clusterio/clusterio/pull/1057>)
- Added links to the ExpCluster and Chat Relay community plugins in the README. [#1062](<https://github.com/clusterio/clusterio/pull/1062>)
### Breaking Changes
- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980](<https://github.com/clusterio/clusterio/issues/980>)
- `checkPermission` and the web UI `hasPermission` helpers take `PermissionName`; TypeScript plugins must augment the `Permissions` interface. [#987](<https://github.com/clusterio/clusterio/issues/987>)
- Replaced `plugins` map with `loadedPlugins` set across core classes (except WebUi). ([#730](<https://github.com/clusterio/clusterio/issues/730>))
- The `/metrics` endpoint requires a user token with the new `core.controller.metrics` permission. Concurrent scrapes share a single gather from the hosts. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
- Config files now have to be explicitly created. Use the `--create-config` flag to get the old behaviour of creating it if missing. [#548](<https://github.com/clusterio/clusterio/issues/548>)
- Renamed clusterioctl's config-control subcommand to config. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
- Renamed clusterioctl's main class from Control to Ctl. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
- Renamed clusterioctl's config entries from control.[name] to ctl.[name]. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
- Plugin entrypoints must now specify the full filename including the `.js` extension. [#984](<https://github.com/clusterio/clusterio/pull/984>)
- Renamed the inventory_sync plugin to player_sync, published as `@clusterio/plugin-player_sync`. Config fields, the inventory view permission, the controller database file and save data are migrated on load. Uninstall `@clusterio/plugin-inventory_sync` before installing the new package. [#1072](<https://github.com/clusterio/clusterio/pull/1072>)
### Meta
- Added a `NO_EXTERNAL_TEST` flag to skip tests that use external APIs. [#976](<https://github.com/clusterio/clusterio/pull/976>)
- Added a CI check that the controller and host stay compatible with the latest published release. [#978](<https://github.com/clusterio/clusterio/pull/978>)
- Added a Lua lint CI job using emmylua_check. [#979](<https://github.com/clusterio/clusterio/pull/979>)
- Added a plugin migration guide for 2.0 and documented that minor versions may contain breaking changes for the plugin API. [#1050](<https://github.com/clusterio/clusterio/pull/1050>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>), [@lntutor](<https://github.com/lntutor>), [@ColumbusLabs](<https://github.com/ColumbusLabs>), [@floze-the-genius](<https://github.com/floze-the-genius>), [@Cooldude2606](<https://github.com/Cooldude2606>), [@solarcloud7](<https://github.com/solarcloud7>), [@Hornwitser](<https://github.com/Hornwitser>), [@ILLISIS](<https://github.com/ILLISIS>), [@Danielv123](<https://github.com/Danielv123>), [@PHIDIAS0303](<https://github.com/PHIDIAS0303>)
## Version 2.0.0-alpha.27
### Features
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
### Fixes
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
### Changes
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
### Meta
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>)
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
### Fixes
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
### Changes
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
### Breaking Changes
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
### Meta
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.23
### Major Features
@@ -345,29 +43,29 @@ Many thanks to the following for contributing to this release:
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
- Extended export-data to produce separate spritesheets for recipes, signals, technologies, planets, qualities, entities, and static UI icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- Instance versions can now be set using Major.Minor without specifing a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and modpacks can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Installations where muliple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
- Removed debug log statement from equipment grid serialization. [#XXX](https://github.com/clusterio/clusterio/pull/XXX)
- Fix that the documentation had a dead link to details of the message passing protocol (#812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix headless download on linux install. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#1234](https://github.com/clusterio/clusterio/issues/1234)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
@@ -689,7 +387,7 @@ Many thanks to the following for contributing to this release:
## Version 2.0.0-alpha.14
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
See the [migration guide](https://github.com/clusterio/clusterio/blob/e0755cb4ee7bce0068c844ab6271da481a843e62/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
### Major Features
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24 AS subspace_storage_builder
FROM node:20 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/subspace_storage.git
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:24 AS clusterio_builder
FROM node:20 as clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
+33 -36
View File
@@ -10,11 +10,9 @@ Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
Clusterio 2.0 is the current stable release and requires Node.js 22 or newer.
For new installations, use the latest Node.js LTS release (see [Installation](#installation)).
The previous stable series is no longer supported.
If you are upgrading from a 2.0 alpha, review the [changelog](CHANGELOG.md) before upgrading.
Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-migration-to-2.0.md).
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
### Ways to support me/the project:
@@ -42,7 +40,7 @@ Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-mig
* [Setting up remote hosts](#setting-up-remote-hosts)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Known issues](#known-issues)
* [Common problems](#common-problems)
* [Common problems](#Common-problems)
## Introduction
@@ -77,7 +75,7 @@ These are the plugins supported and maintained by the Clusterio developers:
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
- [Player Sync](/plugins/player_sync/README.md): Synchronizes your inventory and player settings between servers.
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
There's also plugins developed and maintained by the community:
@@ -85,21 +83,15 @@ There's also plugins developed and maintained by the community:
By Hornwitser.
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
Originally by Godmave, ported to 2.0 by Hornwitser.
- [ExpCluster](https://github.com/explosivegaming/ExpCluster) (@expcluster/*): A collection of plugins and scenario features including role and group syncing, moderation tools, quality of life additions, and discord account linking.
See repo readme for plugin breakdown. Maintained by Cooldude2606.
- [Chat Relay](https://github.com/PHIDIAS0303/chat_relay) (@PHIDIAS0303/chat_relay): Chat relay of Discord and Factorio, seperate channel.
Want to make your own plugin?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
## Installation
Install the latest [Node.js LTS release](https://nodejs.org/en/download), which is recommended for new Clusterio installations.
Clusterio is distributed via npm and comes with a guided install script.
If you already have a supported Node.js version installed, you can set it up in a new directory with:
Clusterio runs on all LTS version of NodeJS, it is distributed via npm and comes with a guided install script.
If you already have a recent Node.js installed, you can set it up in a new directory with:
npm init "@clusterio"
@@ -108,14 +100,12 @@ Check out all our of decisions for [node, factorio, and operation support](/docs
### Ubuntu setup
1. Install the latest Node.js LTS release using the NodeSource repository for Ubuntu/Debian.
The distribution's default repository may provide an older version.
1. Install Node.js v18 or higher.
Note the version of Node.js provided by the Ubuntu/debian repos are too old and you will have to use the nodesource PPA
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - &&\
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
sudo apt-get install -y nodejs
Verify the installation with `node --version` and `npm --version`.
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
mkdir clusterio
@@ -147,7 +137,7 @@ Your instances (save files etc) will be stored there.
### Windows setup
1. Download and install the latest [Node.js LTS release](https://nodejs.org/en/download).
1. Download and install the latest LTS release from http://nodejs.org.
2. Create a new empty directory for the installation and navigate into it.
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
@@ -156,13 +146,12 @@ Your instances (save files etc) will be stored there.
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation, download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
3. If you chose to use the local `factorio` directory for the Factorio installation, you can let the installer download the latest headless release automatically, or manually download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
1. Install the latest [Node.js LTS release](https://nodejs.org/en/download).
If you use [nvm](https://github.com/nvm-sh/nvm#installing-and-updating), run `nvm install --lts`.
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
@@ -178,12 +167,16 @@ Your instances (save files etc) will be stored there.
### Installing Plugins
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
Installing plugins that are not offered by the installer consists of two steps.
First install the package the plugin is provided by via npm, for example
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
npm install @clusterio/plugin-subspace_storage
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
@@ -216,11 +209,7 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
## Managing Factorio mods
Mods in Clusterio are managed as parts of mod packs.
On the Mods page of the web interface, use **Search** to browse the Factorio Mod Portal and download mods to the controller, or upload mod zip files directly.
Mod Portal downloads require the controller's [Factorio account credentials](docs/configuration.md#controllerfactorio_username) to be configured.
Use **Create** in the Mod Packs section to create a mod pack, then add the mods you want to use.
Assign the mod pack to an instance under Instance config -> Factorio -> Mod pack, or set a default mod pack for new instances in the controller settings.
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
![Select mod pack for instance](docs/assets/config_select_mod_pack.png)
@@ -236,9 +225,7 @@ Once this is done, the mod settings will show up under the modpack configuration
## Setting up shared storage
The chests teleporting items to and from the shared storage on the controller has been moved into the Subspace Storage mod and plugin.
To get this to work, install the Subspace Storage plugin on the controller and hosts using the [plugin installation instructions](#installing-plugins).
Download or upload the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) to the controller, add them to a mod pack, and assign it to each participating instance as described in [Managing Factorio mods](#managing-factorio-mods).
Hosts fetch the mods from the controller and synchronize each instance's mods folder with its assigned mod pack when the instance starts.
To get this to work you will need to install the Subspace Storage plugin and copy the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) into the `sharedMods` folder on each host installation.
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
@@ -290,6 +277,16 @@ If you want to use a different user for the command line interface, you can gene
This should be ignored as it does not indicate an error occured.
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
To workaround this find the id of the host in the host config and generate a new host token with the same id.
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
```
localStorage.controller_token = localStorage.master_token
```
## Common problems
+10 -11
View File
@@ -6,11 +6,12 @@
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
import path from "node:path";
import fs from "node:fs/promises";
import yargs from "yargs";
"use strict";
const path = require("path");
const fs = require("fs-extra");
const yargs = require("yargs");
import npmPackage from "./package.json" with { type: "json" };
const npmPackage = require("./package.json");
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
@@ -23,15 +24,13 @@ let DRY = false;
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
try {
await fs.access(filePath);
if (await fs.exists(filePath)) {
// eslint-disable-next-line no-console
console.log(filePath);
} catch {
return;
}
if (!DRY) {
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
await fs.remove(filePath);
}
}
@@ -74,7 +73,7 @@ async function removeTestArtifacts() {
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-ctl.json", "config-controller.json", "config-host.json",
"config-control.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
@@ -115,6 +114,6 @@ async function main() {
}
// Run main if started from command line
if (import.meta.main) {
if (module === require.main) {
main();
}
+2 -41
View File
@@ -3,10 +3,10 @@
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
Plugins can add their own config fields for the controller, host and instance configs.
Plugins can add their own config fields for the controller and instances configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
@@ -46,45 +46,6 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
**validator** (optional):
Function called with the value being set and the config it belongs to.
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
The config is read only while the validator runs.
**dependsOn** (optional):
Names of other fields the validator reads the value of.
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
**restartRequired** (optional):
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
Defaults to false.
**restartRequiredProps** (optional):
For object fields, names of properties that have the opposite of `restartRequired`.
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
**credential** (optional):
Locations allowed to read the value of this field.
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
**readonly** (optional):
Locations allowed to change the value of this field.
Other locations can still read the value if allowed by `access`.
**hidden** (optional):
True if the web interface should not show this field.
Used for internal values that are managed by Clusterio.
**autoComplete** (optional):
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
Only used for string and number fields.
**inputComponent** (optional):
Name of the input component the web interface should render for this field instead of the default for its type.
Plugins can add their own components through `inputComponents` on their web plugin class.
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
+17 -357
View File
@@ -1,15 +1,5 @@
# Configuration
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
All values are checked against the type of the field when set.
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
Setting a value that fails a check is rejected with an error and the old value is kept.
A few fields are internal to Clusterio.
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
## Controller Configuration
@@ -30,30 +20,11 @@ This is also used by plugins to store its data.
Defaults to "database".
### controller.mods_directory
Directory where mods shared with the cluster are stored.
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
Needs to be writeable by the controller.
Defaults to "mods".
### controller.autosave_interval
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
The data is also saved on shutdown.
Must be greater than 0.
Defaults to 60.
### controller.http_port
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and controller.https_port needs to be set to a port.
Must be a whole number greater than 0.
Defaults to 8080.
@@ -64,7 +35,6 @@ Port to host HTTPS server on.
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and controller.http_port needs to be set to a port.
Must be a whole number greater than 0.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
@@ -81,56 +51,27 @@ interfaces.
Defaults to null.
### controller.trusted_proxies
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
Entries that are not valid IP addresses are logged as errors and ignored.
Defaults to null meaning the header is never trusted.
### controller.public_url
Public address the controller is accessible on.
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### controller.static_url
Used to override where static assets such as the web bundle is loaded from.
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
Defaults to null meaning use the internally hosted assets.
### controller.grafana_url
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
If null no link is shown.
Defaults to null.
### controller.tls_certificate
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
Defaults to null.
Defaults to "database/certificates/cert.crt".
### controller.tls_private_key
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
Defaults to null.
Defaults to "database/certificates/cert.key".
### controller.auth_secret
@@ -138,7 +79,6 @@ Defaults to null.
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
Defaults to null which means generate a secure secret on startup.
@@ -148,7 +88,6 @@ Defaults to null which means generate a secure secret on startup.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Must be greater than 0.
Defaults to 15.
@@ -160,94 +99,25 @@ A higher timeout gives more time for a client to recconect and resume an active
In the case of the controller or client crashing the data loss is unavoidable.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
Must be greater than 0.
Defaults to 60.
### controller.max_send_buffer_size
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
The controller keeps these so they can be resent if the connection drops and resumes.
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
Changes apply to new sessions.
Set to 0 for no limit.
Defaults to 64.
### controller.metrics_timeout
Timeout in seconds before a call to gather metrics from a host times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
Must be greater than 0.
Defaults to 8.
### controller.max_subscription_filters
Maximum number of filters one connection can hold for a single subscribed event.
Subscription requests that would go over this limit are rejected.
Set to 0 for no limit.
Lowering the limit does not remove filters connections already hold.
Defaults to 10000.
### controller.system_metrics_interval
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
Whether the controller needs a restart to apply an update is also checked on this interval.
If 0 system metrics are not gathered.
Must be 0 or greater.
Defaults to 10.
### controller.proxy_stream_timeout
### controller.stream_timeout
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Must be greater than 0.
Defaults to 15.
### controller.factorio_username
Username of the Factorio.com account to authenticate with the Factorio API with.
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
Defaults to null.
### controller.factorio_token
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
This is the token shown on your profile page on factorio.com, not your password.
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
Defaults to null.
### controller.share_factorio_credentials_with_hosts
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
Disable this if you don't want the controller's credentials to be sent to the hosts.
Defaults to true.
### controller.default_mod_pack_id
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
Instances without a mod pack of their own fail to start if this is null.
Defaults to null.
### controller.default_role_id
Role to automatically grant to new users.
@@ -256,55 +126,6 @@ If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### controller.mod_portal_cache_duration_minutes
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
Set to 0 to disable the cache and fetch the list on every request.
Must be 0 or greater.
Defaults to 30.
### controller.mod_portal_page_size
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
Must be greater than 0.
Defaults to 1000.
### controller.allow_remote_updates
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### controller.allow_plugin_updates
Whether remote requests to update plugins installed on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to true.
### controller.allow_plugin_install
Whether remote requests to install new plugins on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to false.
### controller.version
Version of the controller package that last loaded this config.
Set automatically on startup and used to detect when the controller has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the controller.
@@ -326,7 +147,6 @@ Defaults to "New Host".
### host.id
Immutable numeric id of the host which uniquely identifies this host in the cluster.
Hidden in the web interface.
Defaults to a random 31 bit number.
@@ -339,14 +159,6 @@ This can either point to a Factorio server directory, or to a directory containi
Defaults to "factorio".
### host.mods_directory
Directory the host stores mods downloaded from the controller in.
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
Defaults to "mods".
### host.instances_directory
Directory to store instances in.
@@ -366,7 +178,6 @@ Defaults to "http://localhost:8080/".
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
@@ -379,82 +190,14 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
### host.factorio_port_range
UDP ports the host hands out to instances that don't have factorio.game_port set.
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
Starting an instance fails if all ports in the range are taken.
Defaults to "34100-34199".
### host.factorio_username
Username of the Factorio.com account to put in the server settings of instances on this host.
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
Defaults to null.
### host.factorio_token
Token of the Factorio.com account to go with host.factorio_username.
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
Defaults to null.
### host.max_reconnect_delay
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be greater than 0.
Defaults to 60.
### host.allow_remote_updates
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### host.allow_plugin_updates
Whether remote requests to update plugins installed on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to true.
### host.allow_plugin_install
Whether remote requests to install new plugins on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to false.
### host.version
Version of the host package that last loaded this config.
Set automatically on startup and used to detect when the host has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the host.
Only plugins with a host or instance part have this field in the host config.
Plugins not loaded on the host will not be loaded on the instances it hosts.
Defaults to true.
## Instance Configuration
### instance.name
@@ -467,7 +210,6 @@ Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
Hidden in the web interface.
Defaluts to a random 31 bit integer.
@@ -477,7 +219,6 @@ Defaluts to a random 31 bit integer.
Host this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a host the directory for the instance is created on the host.
Internal and hidden in the web interface.
Defaults to null meaning not assigned.
@@ -501,52 +242,23 @@ Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
Values that don't match one of these forms are rejected.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
Defaults to "latest".
### factorio.executable_path
Path relative to the Factorio installation directory of the executable to run.
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
Only needed in special setups.
Defaults to null.
### factorio.shutdown_timeout
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
Set to 0 to wait forever.
Changing this while the instance is stopping restarts the timer with the new value.
Must be 0 or greater.
Defaults to 300.
### factorio.game_port
UDP port to run the Factorio server on.
If null a port from host.factorio_port_range on the host is assigned to the instance.
Negative values are rejected.
If null a random port in the dynamyc range assigned each time the instance starts.
Defaults to null.
### factorio.host_assigned_game_port
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
Internal, set by the host and only accessible from the host.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Negative values are rejected.
Defaults to null.
@@ -555,18 +267,16 @@ Defaults to null.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
The value is treated as a credential and can only be read back by the controller and the host.
Defaults to null.
### factorio.player_online_autosave_slots
### player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
The same check is done when factorio.settings is changed.
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
@@ -574,34 +284,12 @@ If 0 no autosave renaming takes place.
Defaults to 5.
### factorio.mod_pack_id
ID of the mod pack to use on this instance.
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
If null the mod pack set in controller.default_mod_pack_id is used.
Defaults to null.
### factorio.enable_save_patching
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Plugins that need save patching can't be loaded on the instance while this is disabled.
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
Defaults to true.
### factorio.enable_script_commands
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
When disabled attempts to send such a command fail with an error.
This does not stop players from using these commands themselves.
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
Defaults to true.
@@ -613,13 +301,6 @@ Turn on the whitelist on the server.
Defaults to false.
### factorio.enable_authserver_bans
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the Factorio server.
@@ -627,7 +308,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### factorio.verbose_logging
@@ -638,14 +319,6 @@ This prints more messages to the log.
Defaults to false.
### factorio.console_logging
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
Useful for 3rd party tools that read the console log of the server.
Defaults to false.
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
@@ -668,35 +341,25 @@ Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.max_concurrent_commands
@@ -713,7 +376,6 @@ This means a high number of concurrent commands can amplify resends due to packe
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
Must be greater than 0.
Defaults to 5.
@@ -721,22 +383,21 @@ Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
Defaults to true.
## Ctl Configuration
## Control Configuration
### ctl.controller_url
### control.controller_url
URL to connect to the controller to.
Defaults to null meaning complain about it not being set and exit.
### ctl.controller_token
### control.controller_token
Access token used for authenticating with the controller.
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
@@ -744,10 +405,9 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### ctl.max_reconnect_delay
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be 0 or greater.
Defaults to 60.
+9 -19
View File
@@ -19,7 +19,7 @@ Although, we recomend checking out out our [past decisions](/docs/decisions.md)
- [Updating Your GitHub Fork](#updating-your-github-fork)
- [Publishing releases to npm](#publishing-releases-to-npm)
- [Changelog](#changelog)
- [Supported Node.js and Factorio Versions](#supported-nodejs-and-factorio-versions)
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
- [Naming Style](#naming-style)
- [Units](#units)
@@ -77,7 +77,7 @@ The documentation uses the binaries provided by the packages, but for the develo
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
Note that to use `clusterioctl` you will have to create a ctl config first:
Note that to use `clusterioctl` you will have to create a control config first:
node packages/controller bootstrap create-ctl-config [admin-user]
@@ -125,11 +125,6 @@ Please do add tests for any code you add and run both:
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
The full test suite can take a while to run.
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
### Submitting a Pull Request
@@ -142,8 +137,6 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
### Editing your Feature Branch
@@ -200,15 +193,12 @@ The format of this log should be self explanatory, please follow it carefully. P
## Supported Node.js and Factorio Versions
Clusterio requires Node.js 22 or newer and supports LTS releases in their active and maintenance phases.
For a new development environment, install the latest LTS release using the [installation instructions](../README.md#installation).
Make sure the Node.js built-in APIs and npm packages you use remain compatible with the supported versions.
The GitHub Actions tests currently run on Node.js 24 and 26 across the Factorio matrix, with Node.js 22 used for coverage; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
Clusterio run on Node.js v18 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The GitHub Actions tests are run on whatever version of Node.js v18 and v20 is the default.
For Factorio, active support covers the latest stable version and the previous major version, using version lines such as 2.1, 2.0 and 1.1 as described in the [Factorio support policy](decisions.md#factorio).
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
Check the Lua API documentation for the Factorio versions your changes target and preserve compatibility with the actively supported versions.
The GitHub Actions tests use pinned Factorio releases; see the [CI workflow](../.github/workflows/node.js.yml) for the current version matrix.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The GitHub Actions tests are run against the latest experimental release.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
@@ -232,8 +222,8 @@ Files containing and exporting a single class should be named the same as the cl
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
import libLink from "./link";
import libLuaTools from "./lua_tools";
const libLink = require("./link");
const libLuaTools = require("./lua_tools");
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
+7 -7
View File
@@ -361,16 +361,16 @@ While we have considered alternatives since, transitioning to another solution w
### Breaking Changes
🧾 7th October 2026
🧾 Time Immemorial
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
> All breaking changes are documented in the changelog along with migration actions.
> We follow symver versioning for breaking changes.
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
> Once we enter beta, breaking changes will be more restricted.
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
Changes to the plugin API may still be made in minor versions.
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
### Distribution
+8 -3
View File
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
* A Data class example
* @alias module:Data
*/
export class Data {
class Data {
/** @type {string} */
member = "foo";
@@ -41,6 +41,8 @@ export class Data {
return json;
}
}
module.exports = Data;
```
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
@@ -51,13 +53,14 @@ For example a class holding a data property with an instance of the Data class f
```js
// Composed.js
import Data from "./Data";
"use strict";
const Data = require("./Data");
/**
* Composed data class example
* @alias module:Data
*/
export class Composed {
class Composed {
/** @type {module:Data|undefined} */
data;
@@ -80,6 +83,8 @@ export class Composed {
// toJSON is optional and not needed for this particular class
}
module.exports = Composed;
```
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
-333
View File
@@ -1,333 +0,0 @@
# Migrating Plugins to 2.0
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
## Versioning from 2.0
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
## Convert to ES modules
Clusterio's packages are now ES modules and plugins should be too.
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
Keep `"clusterio-plugin"` in the package's `"keywords"` for automatic discovery, and keep `"main"` pointing to the plugin info module (or use the default `index.js`).
An `"exports"` map is not required for ES modules.
If you add one, expose `"./package.json": "./package.json"`; otherwise Clusterio cannot discover or load the plugin by its package name.
Clusterio currently reads the implementation path from `"main"` rather than conditional exports.
See [Plugin Structure](../writing-plugins.md#plugin-structure) for a compatible package example.
Relative imports must include the full filename with the `.js` extension.
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
Importing a directory no longer resolves to its `index.js`.
**Alpha 27**
```js
"use strict";
const lib = require("@clusterio/lib");
const messages = require("./messages");
const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller",
messages: [messages.Frobnicate],
};
module.exports = {
plugin,
};
```
**2.0**
```js
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
export const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller.js",
messages: [messages.Frobnicate],
};
```
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
Converting the plugin avoids the problem.
## Replace plugin classes with entrypoint functions
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
Class exports still load, but they are deprecated and a warning is logged for each one.
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
**Alpha 27**
```js
const { BaseControllerPlugin } = require("@clusterio/controller");
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
}
async onSaveData() {
await this.saveFrobnicationData();
}
async onInstanceStatusChanged(instance, prev) {
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
}
async handleFrobnicate(request) { /* ... */ }
}
module.exports = {
ControllerPlugin,
};
```
**2.0 (JS)**
```js
export default async function(context) {
const { controller, logger, plugin } = context;
controller.handle(Frobnicate, async (request) => { /* ... */ });
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
});
}
```
**2.0 (TS)**
```ts
import type { ControllerPluginContext } from "@clusterio/controller";
export default async function(context: ControllerPluginContext) {
// Same as above
}
```
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
### Keeping a class for state
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
`fromContext` can then be the default export of the entrypoint.
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
```ts
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
import type * as lib from "@clusterio/lib";
export class Frobber {
private static instances = new WeakMap<Controller, Frobber>();
private frobnications = new Map<string, number>();
private constructor(
private controller: Controller,
private logger: lib.Logger,
) { }
static async fromContext(context: ControllerPluginContext) {
const frobber = new Frobber(context.controller, context.logger);
await frobber.load();
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
Frobber.instances.set(context.controller, frobber);
return frobber;
}
static get(controller: Controller) {
return Frobber.instances.get(controller);
}
async load() { /* ... */ }
async save() { /* ... */ }
}
export default Frobber.fromContext;
```
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
### Method to hook names
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
The exceptions are:
- `onSaveData` on the controller is `controller.hooks.save`.
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
The ctl hooks are passed directly as `context.hooks`.
**Alpha 27**
```jsx
class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
];
}
}
```
**2.0**
```jsx
export default async function(context) {
const { control, plugin } = context;
control.hooks.pages.attach(plugin.name, () => [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
]);
}
```
### Replacing other class members
The class properties are on the context object passed to the entrypoint:
- `this.info` is `context.plugin`.
- `this.logger` is `context.logger`.
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
- `this.metrics` on controller plugins is `context.metrics`.
- `this.package` on web plugins is `context.package`.
The helper methods of the base classes have the following replacements:
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
The plugin name is used to label the RCON metrics.
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
- `this.sendOrderedRcon(message)` has no replacement.
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
### Changes to hook behaviour
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
Don't rely on another plugin's handler having finished before yours runs.
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
Each hook accepts one handler per name, attach it under `plugin.name`.
## Accessing other plugins
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
```js
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
const loaded = info !== undefined && controller.loadedPlugins.has(info);
```
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
`control.plugins` is deprecated and only holds plugins still using the class export.
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
```ts
import { Frobber } from "other_plugin/dist/node/controller.js";
const frobber = Frobber.get(controller);
if (!frobber) {
throw new Error("other_plugin is not loaded");
}
```
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
The web plugin template created by `@clusterio/create` shows this for a subscriber.
## Declare permissions on the plugin
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
`lib.definePermission()` still works, but don't use both for the same permission name.
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
```ts
declare module "@clusterio/lib" {
export interface Permissions {
"foo_frobber.frobnicate": never;
}
}
```
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
## Renamed ctl classes and config
The remaining uses of "control" for clusterioctl were renamed to "ctl":
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
Fields under the old name are silently ignored.
- The `ControlConfigFields` interface is `CtlConfigFields`.
- `lib.ControlConfig` is `lib.CtlConfig`.
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
## Static assets in the web interface
`staticRoot` now includes the `static/` prefix.
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
## Package dependencies
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
```json
"peerDependencies": {
"@clusterio/controller": "^2.0.0",
"@clusterio/host": "^2.0.0",
"@clusterio/lib": "^2.0.0"
},
"peerDependenciesMeta": {
"@clusterio/controller": {
"optional": true
},
"@clusterio/host": {
"optional": true
}
}
```
+1 -1
View File
@@ -14,7 +14,7 @@ All messages sent and received are validated with JSON schemas to catch potentia
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
For ctl there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
Messages received invokes message handlers that were registered when the link was brought up.
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
-2
View File
@@ -5,8 +5,6 @@ The save patching is based on the built in [event_handler](https://github.com/wu
Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
+5 -53
View File
@@ -5,7 +5,7 @@ This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` s
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
## Hosts
@@ -30,10 +30,9 @@ Create a host config. `--id` id the host id. `--name` is the host name. `--gener
### Update Clusterio
ctl> host update <host> [--all] [--restart]
ctl> host update [--id <host id>] [--name <host name>] [--restart]
Specify the host by name or ID.
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Add `--all` to also update all npm-installed plugins. 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.
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:
@@ -41,17 +40,9 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
The controller also supports remote updates, following the same logic. The corresponding commands are:
ctl> controller update [--all] [--restart]
ctl> controller update [--restart]
controller> config set controller.allow_remote_updates true/false
### Mark restart required
ctl> host mark-restart-required <host>
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
ctl> controller mark-restart-required
## Instances
### List Instances
@@ -74,12 +65,6 @@ Terminates the running Factorio server without giving it a chance to save or cle
Note: This may cause loss of data.
### Save game
ctl> instance save-game <name>
Saves the running game of the instance to the save it was started from and waits for the save to finish.
### Extract players from instance
ctl> instance extract-players <name>
@@ -117,7 +102,7 @@ Edit instance config in a text editor. Once file is saved and editor has been ex
### Assign instance to host
ctl> instance assign <instance> [host]
ctl> instace assign <instance> [host]
Assigns instance to host. `instance` is instance to assign, `host` is host to assign to. If `host` is not provided, instance will be unassigned.
@@ -256,13 +241,6 @@ This command lists all plugins currently known to the remote. Newly installed pl
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Update All
ctl> controller update --all [--restart]
ctl> host update <host> --all [--restart]
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
### Install
ctl> controller plugin install <name> [--restart]
@@ -283,29 +261,3 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
controller> config set controller.allow_plugin_install true/false
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
## Prometheus Metrics
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
Create a role with just this permission, a user to hold it, and generate a token for that user.
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
ctl> role create Prometheus --permissions core.controller.metrics
ctl> user create #prometheus
ctl> user set-roles #prometheus Prometheus
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
Then pass the token in the scrape job for the controller in `prometheus.yml`:
scrape_configs:
- job_name: clusterio
authorization:
credentials: <token>
static_configs:
- targets: ["localhost:8080"]
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
+46
View File
@@ -0,0 +1,46 @@
# Migrating from 2.0.0-alpha.13 to 2.0.0-alpha.14
Alpha 14 comes with significant breaking changes for existing clusters, most notably the rename of master and slave to controller and host.
Please study the entire migration guide carefully before attempting to update an existing cluster.
## Before updating
If you're using mods you need to create mod pack string for the mod configurations you're currently running.
You can do this by running the follow RCON command on an instance that is currently running the mod configuration you want to use (you can also run this command in single player and find the string printed in Factorio's [Log file](https://wiki.factorio.com/Log_file)):
/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:
eNqFketuwyAMhd/Fv0OUVpW25lWmqSLESdAIRNh0l4h3n9mlitZt5QdCx/7so8MKXs8ILSzaPEEFPZKJdmEbvIgiDNpwiDaczhjpU93Vu/r+Tmpz6Anah/V7RqcJRUavO4c9tBwTVnAF5upCGJeIsYx3tvsPbQTdb0lKHYlnPJHY0+ONvcdjvW8gP1ZAyGz9KL5XINaR0yLPXEFMnu2ManSh0+6j/GOFitqXG4mjNSUidVm5wlm7JMYG7Qhl3BX8FjyqZ9vztOk+NM2fvRPaceLbzbN+UejQFFOWXzfArvk6v2HWD9ZLv5JfxA1UwsubPBaMKhHGklLO76vAwfQ=
If you have multiple mod configuration create a mod pack string for each and note them down.
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
## Migrate installations
**Note:** Check that plugins you are using have updates avaialble for alpha 14 before attempting to update, if they do not either wait for the plugin to be updated or uninstall the plugin from all places it is installed on first.
Shutdown all slaves and the master server and then migrate each installations by running the following command:
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
If you're using systemd then copy the generated clusteriocontroller.service and/or clusteriohost.service files from the systemd folder to /etc/systemd/system/ and remove the old clusteriomaster.service and clusterioslave.service files.
Once the installations have been migrated start up the controller and hosts again.
## Post installation
Import the Mod Pack strings that was created beforehand on the controller and upload the mods needed by those mod packs to the controller.
You can assign a default mod pack in the controller config which instances use by default or set the mod pack on the per instance basis in the instance config.
Once you have assigned a mod pack to an instance run a data export on the instance in order to properly populate the mod settings in the mod and get item icons and item names resolved in the web interface.
+9 -30
View File
@@ -5,18 +5,10 @@
- CHANGELOG.md in the repository root
- The changelog.txt file in mod directories
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
Pull requests will usually have suggestions for changelog entries.
## Packages
The [Publish workflow](../.github/workflows/publish.yml) publishes npm packages and Lua mods when a GitHub release is published.
For this workflow, first merge the changelog and package version updates into `master`, then create and push the release tag on that merged commit.
Create a draft GitHub release for the existing tag and use the GitHub Release Markdown from `scripts/pr_to_changelog.mts` as its notes.
Publishing the draft triggers the workflow; pushing a tag alone does not.
The steps below describe publishing npm packages manually instead.
> [!NOTE]
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
@@ -28,38 +20,25 @@ The steps below describe publishing npm packages manually instead.
git status
```
2. Create a release branch and bump the version in the `package.json` files. The commands below use 2.0.1 to 2.0.2 as an example; substitute the versions for the release you are preparing.
2. Bump the version in the `package.json` files
```sh
git switch -c release/2.0.2
sed -i 's/"version": "2\.0\.1"/"version": "2.0.2"/' {packages,plugins}/*/package.json
pnpm install --no-frozen-lockfile
```
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
````
3. Review and commit the version and changelog changes, then open and merge a release pull request.
3. Tag and push the release to github
```sh
git add -p
git commit -m "Prepare 2.0.2 release"
git commit -m v2.0.0-alpha.XX
git tag v2.0.0-alpha.XX
```
4. After the release pull request is merged, tag the merged commit and push the tag.
```sh
git switch master
git pull --ff-only upstream master
git tag v2.0.2
git push upstream v2.0.2
```
5. For manual publication, perform a clean install and publish the packages. For automated publication, publish the GitHub release as described above instead.
The repository does not track `pnpm-lock.yaml`, so allow pnpm to generate or update it during installation.
4. Perform a clean install and publish the packages.
```sh
pnpm clean
pnpm install --no-frozen-lockfile
pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish
pnpm -r --filter='./{plugins,packages}/*' publish
```
## Plugins
+107 -298
View File
@@ -1,17 +1,15 @@
# Writing Plugins
Plugins for Clusterio are JavaScript modules that run under Node.js.
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
## Contents
- [Plugin Structure](#plugin-structure)
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
- [Defining the plugin class](#defining-the-plugin-class)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
- [Plugin Permissions](#plugin-permissions)
- [Migrating Stored Data](#migrating-stored-data)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
@@ -29,7 +27,7 @@ The basic file structure of a plugin is the following.
+- package.json
+- controller.js
+- instance.js
+- ctl.js
+- control.js
+- test/
| +- plugin.js
+- module/
@@ -39,8 +37,6 @@ The basic file structure of a plugin is the following.
Clusterio plugins are Node.js packages that can be published on npm for ease of installation and distribution.
The usual guides for creating such packages apply.
At minimum the `package.json` file must contain a version entry.
For automatic discovery, include `"clusterio-plugin"` in the package's `"keywords"` array.
Installed npm plugins are discovered from the installation's dependencies when controller, host or ctl loads its configuration, and added to `plugin-list.json`.
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
@@ -49,37 +45,15 @@ Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on W
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
Keep this `"main"` entry when using a package `"exports"` map: Clusterio currently loads the plugin implementation from `"main"` (or `index.js`), rather than selecting a conditional export.
If a plugin published on npm defines an `"exports"` map, it must also expose `"./package.json"`.
Both automatic discovery and runtime loading resolve `<package-name>/package.json` to read the plugin's metadata.
Packages that hide this file are currently skipped by discovery, and manually adding their package names to `plugin-list.json` does not make them loadable.
The generated plugin templates omit `"exports"`, which leaves `package.json` accessible.
For example, a plugin that needs an export map can use:
```json
{
"name": "example-clusterio-plugin",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"keywords": ["clusterio-plugin"],
"exports": {
".": "./index.js",
"./package.json": "./package.json"
}
}
```
Here's an example of `index.js`:
```js
export const plugin = {
module.exports.plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance.js",
controllerEntrypoint: "controller.js",
instanceEntrypoint: "instance",
controllerEntrypoint: "controller",
messages: {
/* See below */
},
@@ -89,10 +63,8 @@ export const plugin = {
The following properties are recognized:
**name**:
Stable internal identifier of the plugin, used as its key in the plugin list.
For local plugins automatically discovered under `plugins/` or `external_plugins/`, it must match the plugin's directory name.
It does not have to match the npm package name.
For example, the package `@clusterio/plugin-subspace_storage` declares the name `subspace_storage`.
Internal name of the plugin.
Must be the same as the plugin's directory name
**title**:
Name of the plugin as shown to users.
@@ -103,7 +75,7 @@ The following properties are recognized:
Currently not used.
**instanceEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
@@ -112,104 +84,86 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**controllerEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the controller.
**controllerConfigFields**:
Object mapping controller configuration fields to `FieldDefinition` objects for this plugin.
**ControllerConfigGroup**:
Object mapping controller configuration fields to `FieldDefinitions`for this plugin.
See [Plugin Configuration](#plugin-configuration)
**ctlEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
This is an optional paramater.
A plugin can be made that only runs on the clusterioctl side.
**migrationEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
This is an optional parameter.
See [Migrating Stored Data](#migrating-stored-data)
**features**:
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
See [Handling Invalid Configuration](#handling-invalid-configuration)
**messages**:
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
**permissions**:
Array of permission definitions for this plugin.
See [Plugin Permissions](#plugin-permissions)
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
You can put them into whatever file you want (even the same one for both).
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
## Defining the plugin entrypoint
## Defining the plugin class
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
For example, a controller entrypoint can look like this:
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a ControllerPlugin class the following code can be used:
```js
export default async function(context) {
const { controller, logger, plugin } = context;
const { BaseControllerPlugin } = require("@clusterio/controller");
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
}
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("Stopping frobnication");
});
// ...
}
module.exports = {
ControllerPlugin,
}
```
The context has the following properties:
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
The available hooks that you can override are documented in the base classes.
**plugin**:
The plugin's own info, as exported from the main entrypoint.
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
**logger**:
Logger for this plugin, see [Logging Messages](#logging-messages).
```js
constructor(...args) {
super(...args);
**controller**, **host**, **instance**, **hooks** or **control**:
The object the entrypoint is loaded for.
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
// Code here
}
```
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
### Class based plugins
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
The async init method is always called immediatly after the constructor, so there's little reason to do this.
## Logging Messages
The load context provides a winston logger for logging messages to the shared cluster log.
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the host the instance is on.
To use it, pass a string to one of the log levels functions, for example:
```js
export default async function(context) {
context.logger.info("Initializing frobbing");
async init() {
this.logger.info("Initializing frobbing");
}
```
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
The available levels are fatal, error, warn, audit, info, and verbose.
These levels have roughly the following meanings:
@@ -235,7 +189,7 @@ For guarding unexpected errors the best option is to log a short description alo
try {
// Operation that should not throw but may end up throwing
} catch (err) {
logger.error(`Operation failed:\n${err.stack}`);
this.logger.error(`Operation failed:\n${err.stack}`);
)
```
@@ -259,7 +213,7 @@ declare module "@clusterio/lib" {
}
}
export const plugin = {
export default {
...
controllerConfigFields: {
"foo_frobber.level": {
@@ -271,12 +225,12 @@ export const plugin = {
} satisfies lib.PluginDeclaration;
```
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
```ts
export default async function(context: ControllerPluginContext) {
const level = context.controller.config.get("foo_frobber.level");
context.logger.info(`I got a frobnication level of ${level}`);
async init() {
let level = this.controller.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
}
```
@@ -286,144 +240,33 @@ See [Configuration System](config-system.md) for more details on how this system
### Handling Invalid Configuration
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
For example:
```js
export const plugin = {
name: "foo_frobber",
// ...
features: ["SavePatching"],
};
```
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
See [Configuration System](config-system.md) for details.
## Plugin Permissions
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
Names must start with the plugin name followed by a dot.
For example in index.ts:
```ts
import type * as lib from "@clusterio/lib";
declare module "@clusterio/lib" {
// Extend the interface of known permission names so that
// user.checkPermission(...) accepts our permission.
export interface Permissions {
"foo_frobber.frobnicate": never;
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
}
}
export const plugin = {
...
permissions: [
{
name: "foo_frobber.frobnicate",
title: "Frobnicate",
description: "Run frobnication on instances.",
grantByDefault: false, // Whether the generated Player role is granted this permission
},
],
} satisfies lib.PluginDeclaration;
```
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
In JavaScript the `declare module` block is not needed.
Permissions declared this way are registered when the plugin is loaded.
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
## Migrating Stored Data
Clusterio stores the config fields and permissions of plugins, so a plugin that renames them needs to migrate the stored values or users lose what they had set.
This is done from the migration entrypoint, which is loaded on the controller and hosts before any config or role is read, for all plugins in the plugin list including those disabled with `load_plugin`.
It is passed a context with `hooks` and attaches handlers for the data it needs to migrate:
```js
import * as lib from "@clusterio/lib";
export default function(context) {
const { hooks, plugin } = context;
// Plugin was renamed from frobber in 2.0.0
const renameFields = config => lib.renameConfigPrefix(config, "frobber.", "foo_frobber.");
hooks.controllerConfig.attach(plugin.name, renameFields);
hooks.hostConfig.attach(plugin.name, renameFields);
hooks.instanceConfig.attach(plugin.name, renameFields);
hooks.role.attach(plugin.name, role => lib.renamePermissionPrefix(role, "frobber.", "foo_frobber."));
}
```
The handlers get the serialized config or role after the core migrations and return the migrated version.
They run synchronously, a handler that throws stops the controller or host from starting.
`lib.renameConfigField`, `lib.renameConfigPrefix`, `lib.renamePermission` and `lib.renamePermissionPrefix` cover the common case of renaming.
Handlers run every time the data is loaded and must leave data that is already migrated unchanged.
For changes that can't be repeated, each config has a hidden `<plugin>.version` field with the version of the plugin it was last loaded with, which is updated after the migrations have run:
```js
hooks.instanceConfig.attach(plugin.name, config => {
const version = config["foo_frobber.version"];
// The default delay went from 1 to 2 in 1.2.0, update configs still on the old default
if (
version && lib.integerFullVersion(version) < lib.integerFullVersion("1.2.0")
&& config["foo_frobber.delay"] === 1
) {
config["foo_frobber.delay"] = 2;
}
return config;
});
```
The version field is missing or empty in configs saved before Clusterio tracked it, and in configs last loaded in recovery mode.
Roles have no version of their own.
They are stored on the controller, so use the version in the controller config, which is loaded before the roles:
```js
let controllerVersion;
hooks.controllerConfig.attach(plugin.name, config => {
controllerVersion ??= config["foo_frobber.version"] ?? "";
return config;
});
hooks.role.attach(plugin.name, role => {
// foo_frobber.edit was split out of foo_frobber.view in 1.2.0
if (
controllerVersion && lib.integerFullVersion(controllerVersion) < lib.integerFullVersion("1.2.0")
&& role.permissions.includes("foo_frobber.view")
) {
role.permissions.push("foo_frobber.edit");
}
return role;
});
```
Data the plugin stores itself, like files in the database directory or values in the save, is up to the plugin to migrate from its other entrypoints.
Modules don't need migrating, save patching replaces the files of every module when an instance starts.
## Communicating with Factorio
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
This is done by calling the `sendRcon` method on the instance.
This is done by calling the `sendRcon` method on the plugin object.
For example:
```js
instance.hooks.start.attach(plugin.name, async () => {
const response = await instance.sendRcon(
async onStart() {
let response = await this.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
});
}
```
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
@@ -436,16 +279,15 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
For example in the plugin code:
```js
export default async function(context) {
const { instance, logger } = context;
instance.server.on("ipc-my_plugin_foo", content =>
handleFoo(content).catch(err => logger.error(
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => this.logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
async function handleFoo(content) {
async handleFoo(content) {
// Do stuff with content
}
```
@@ -462,11 +304,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
For convience a helper function is exposed on server specifically for handling IPC events.
```js
export default async function(context) {
context.instance.server.handle("my_plugin_foo", handleFoo);
async init() {
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
}
async function handleFoo(content) {
async handleFoo(content) {
// Do stuff with content
}
```
@@ -603,34 +445,15 @@ Link messages are sent by calling the `.sendTo()` method on a connection object
For example:
```js
// In an instance entrypoint
async function frobnicate(instance) {
const response = await instance.sendTo("controller", new messages.Frobnicate({
// In an InstancePlugin class
async frobnicate() {
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
console.log(response); // { report: [...] }
}
```
Because the send and handle methods rely on this, you should always call them directly on the connection object.
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
Here is what that looks like in practice:
```js
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
const send = instance.sendTo;
send("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
```
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
@@ -651,23 +474,23 @@ sendTo({ controlId: 123 }, message);
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between controller and host.
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
```js
if (host.connected) {
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
if (this.host.connected) {
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
}
```
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
@@ -683,7 +506,7 @@ In its simplest form collecting data from plugins consists of defining the metri
For example:
```js
import { Counter } from "@clusterio/lib";
const { Counter } = require("@clusterio/lib");
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
@@ -693,13 +516,13 @@ const fooMetric = new Counter(
fooMetric.inc();
```
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
```js
import { Counter } from "@clusterio/lib";
const { Counter } = require("@clusterio/lib");
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
@@ -707,48 +530,23 @@ const barMetric = new Gauge(
);
// Somewhere in the instance plugin code
barMetric.labels(String(instance.id)).set(someValue);
barMetric.labels(String(this.instance.id)).set(someValue);
```
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
```js
import { Gauge } from "@clusterio/lib";
const frobnicationsMetric = new Gauge(
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
);
// In the controller entrypoint
controller.hooks.metrics.attach(plugin.name, async () => {
frobnicationsMetric.set(frobnications.size);
});
```
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
```js
import { CollectorRegistry, Counter } from "@clusterio/lib";
const fooRegistry = new CollectorRegistry();
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
fooRegistry.register(fooCounter);
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
```
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
## Adding Custom Commands to clusterioctl
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
The control entrypoint for plugins allows you to extend clustectl with your own commands.
The creation of custom commands typically starts with defining a command tree for the plugin:
```js
import { Command, CommandTree } from "@clusterio/lib";
const { Command, CommandTree } = require("@clusterio/lib");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
@@ -757,30 +555,41 @@ const fooFrobberCommands = new CommandTree({
Then commands are added to the the plugin's command tree:
```js
import * as messages from "./messages.js";
const info = require("./info");
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("type", {
yargs.positional("level", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, ctl) {
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
},
}));
```
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
```js
export default async function(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
const { BaseCtlPlugin } = require("@clusterio/ctl");
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
});
}
}
module.exports = {
CtlPlugin,
}
```
+2 -3
View File
@@ -148,7 +148,6 @@ export default defineConfig([{
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
"newIsCapExceptions": ["constructor"],
}],
"new-parens": "error",
@@ -419,7 +418,7 @@ export default defineConfig([{
...globals.mocha,
},
sourceType: "module",
sourceType: "commonjs",
parserOptions: {},
},
@@ -490,7 +489,7 @@ export default defineConfig([{
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
+30 -33
View File
@@ -2,8 +2,7 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"type": "module",
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
@@ -11,25 +10,23 @@
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
"cover": "c8 pnpm test",
"fast-cover": "FAST_TEST=y c8 pnpm test",
"ci-cover": "c8 --reporter=lcovonly pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
"c8": {
"nyc": {
"exclude": [
"test/**",
"plugins/*/test/**",
"temp/**"
"plugins/*/test/**"
]
},
"engines": {
"node": ">=22"
"node": ">=20"
},
"workspaces": [
"packages/*",
@@ -38,29 +35,29 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@swc/core": "catalog:",
"@types/node": "catalog:",
"c8": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"eslint": "^10.10.0",
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.3",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"eslint-plugin-node": "^11.1.0",
"express": "catalog:",
"form-data": "^4.0.6",
"globals": "^17.12.0",
"jsonwebtoken": "catalog:",
"jszip": "catalog:",
"mocha": "^11.8.0",
"typedoc": "^0.28.20",
"typescript": "catalog:",
"yargs": "catalog:"
"express": "^5.1.0",
"form-data": "^4.0.4",
"fs-extra": "^11.3.0",
"globals": "^16.3.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
}
}
+6 -37
View File
@@ -15,12 +15,9 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the controller.
Defaults to `config-controller.json` and is required for operation.
Defaults to `config-controller.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -28,28 +25,17 @@ Configure plugins available to be loaded by the controller.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, adding a plugin checked out in the external_plugins directory:
For example, installing the Subspace Storage plugin:
npx clusteriocontroller plugin add ./external_plugins/my_plugin
npm install @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -77,13 +63,6 @@ Manage the controller configuration offline.
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
@@ -125,16 +104,6 @@ This can be used both in the web interface and as the `controller_token` to conn
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
### `copy-static [target]`
Copies the static web assets to the given target folder (which defaults to `./static/`).
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
See also the `controller.public_url` config option for setting where static assets are loaded from.
### `run`
Runs the controller.
+75 -89
View File
@@ -15,9 +15,10 @@
*/
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -27,12 +28,12 @@ import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller.js";
import UserManager from "./src/UserManager.js";
import UserRecord from "./src/UserRecord.js";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
// globals
let controller: Controller;
@@ -101,9 +102,6 @@ void new lib.Gauge(
"clusterio_controller_websocket_active_connections",
"How many WebSocket connections are currently open to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.activeConnectors.size);
}}
);
@@ -112,9 +110,6 @@ void new lib.Gauge(
"clusterio_controller_active_hosts",
"How many hosts are currently connected to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.hostConnections.size);
}}
);
@@ -124,9 +119,6 @@ void new lib.Gauge(
"How many clients are currently connected to this controller",
{
labels: ["type"], callback: async function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.labels("host").set(controller.wsServer.hostConnections.size);
gauge.labels("control").set(controller.wsServer.controlConnections.size);
},
@@ -190,8 +182,7 @@ async function handleBootstrapCommand(
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
));
} else if (subCommand === "create-ctl-config") {
@@ -201,59 +192,25 @@ async function handleBootstrapCommand(
process.exitCode = 1;
return;
}
let ctlConfig = new lib.CtlConfig("control");
let controlConfig = new lib.ControlConfig("control");
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
ctlConfig.set(
"ctl.controller_token",
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
userManager.signUserToken(admin),
);
let content = JSON.stringify(ctlConfig, null, "\t");
let content = JSON.stringify(controlConfig, null, "\t");
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
await lib.safeOutputFile(args.output, content);
}
}
}
async function handleCopyStaticCommand(
args: any,
pluginInfos: lib.PluginNodeEnvInfo[],
) {
await fs.cp(
path.join(import.meta.dirname, "..", "web", "static"),
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
logger.info("Copied main web interface files");
for (const pluginInfo of pluginInfos) {
try {
await fs.cp(
pluginInfo.webStaticPath,
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
} else {
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
process.exitCode = 1;
}
}
logger.info(`Copied web interface files for ${pluginInfo.name}`);
}
}
export interface ControllerArgs {
[x: string]: unknown;
@@ -277,7 +234,7 @@ interface InitializeParameters {
async function initialize(): Promise<InitializeParameters> {
// argument parsing
const args = await yargs(process.argv.slice(2))
const args = await yargs
.scriptName("controller")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -299,12 +256,6 @@ async function initialize(): Promise<InitializeParameters> {
default: "config-controller.json",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -328,18 +279,11 @@ async function initialize(): Promise<InitializeParameters> {
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
nargs: 1, default: "config-ctl.json",
nargs: 1, default: "config-control.json",
});
})
.demandCommand(1, "You need to specify a command to run");
})
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
yargs.positional("target", {
describe: "Target directory to copy files to",
type: "string",
default: "static",
});
})
.command("run", "Run controller", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
@@ -355,7 +299,7 @@ async function initialize(): Promise<InitializeParameters> {
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.parse()
.argv
;
// Combined log stream of the whole cluster.
@@ -392,7 +336,7 @@ async function initialize(): Promise<InitializeParameters> {
let command = args._[0];
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${packageConfig.version}`);
logger.info(`Starting Clusterio controller ${version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
@@ -417,19 +361,31 @@ async function initialize(): Promise<InitializeParameters> {
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
if (!args.recovery) {
await lib.loadPluginMigrations(pluginInfos);
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
args,
lib.ControllerConfig,
"controller",
);
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
}
}
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
@@ -443,8 +399,6 @@ async function initialize(): Promise<InitializeParameters> {
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "copy-static") {
await handleCopyStaticCommand(args, pluginInfos);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
@@ -491,9 +445,33 @@ async function startup() {
process.exit(1);
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
@@ -503,6 +481,14 @@ async function startup() {
}
export function bootstrap() {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startup().catch(err => {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
logger.fatal(`
@@ -532,6 +518,6 @@ ${err.stack}`
});
}
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+14 -14
View File
@@ -1,17 +1,17 @@
import { bootstrap } from "./controller.js";
export { default as Controller } from "./src/Controller.js";
export { default as ControllerRouter } from "./src/ControllerRouter.js";
export { default as ControlConnection } from "./src/ControlConnection.js";
export { default as HostConnection } from "./src/HostConnection.js";
export { default as InstanceRecord } from "./src/InstanceRecord.js";
export { default as InstanceManager } from "./src/InstanceManager.js";
export { default as HostRecord } from "./src/HostRecord.js";
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
export { default as UserManager } from "./src/UserManager.js";
export { default as UserRecord } from "./src/UserRecord.js";
export { default as User } from "./src/User.js";
export { default as WsServer } from "./src/WsServer.js";
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceRecord } from "./src/InstanceRecord";
export { default as InstanceManager } from "./src/InstanceManager";
export { default as HostRecord } from "./src/HostRecord";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as UserRecord } from "./src/UserRecord";
export { default as User } from "./src/User";
export { default as WsServer } from "./src/WsServer";
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+30 -41
View File
@@ -1,28 +1,9 @@
{
"name": "@clusterio/controller",
"version": "2.0.2",
"description": "Implementation of Clusterio controller",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -31,44 +12,52 @@
"bin": {
"clusteriocontroller": "./dist/node/controller.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.2",
"express": "catalog:",
"finalhandler": "^2.1.1",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"semver": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "catalog:"
"busboy": "^1.6.0",
"compression": "^1.8.0",
"finalhandler": "^1.3.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.4",
"@types/compression": "^1.8.1",
"@types/express": "catalog:",
"@types/finalhandler": "catalog:",
"@clusterio/web_ui": "workspace:*",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "catalog:",
"@types/yargs": "^17.0.33",
"@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-dev-middleware": "^6.1.3",
"webpack-merge": "catalog:"
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
}
}
+7 -14
View File
@@ -1,12 +1,12 @@
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import * as routes from "./routes.js";
const { logger } = lib;
import * as routes from "./routes";
/**
@@ -33,7 +33,6 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -80,7 +79,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: lib.SourceVersion, 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) {
@@ -95,12 +94,10 @@ export default class BaseConnection extends lib.Link {
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
// Open source file before creating the stream in case it throws.
let source = (await fs.open(modPath)).createReadStream();
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = source;
stream.source = fs.createReadStream(modPath);
stream.mime = "application/zip";
stream.size = String(mod.size);
@@ -114,8 +111,4 @@ export default class BaseConnection extends lib.Link {
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
return await this._controller.latestReleases.get(request.maxAgeMs);
}
}
+50 -126
View File
@@ -1,35 +1,45 @@
import type Controller from "./Controller.js";
import type InstanceRecord from "./InstanceRecord.js";
import type ControlConnection from "./ControlConnection.js";
import type HostConnection from "./HostConnection.js";
import * as lib from "@clusterio/lib";
export type ControllerPluginContext = lib.PluginLoadContext<{
controller: Controller;
metrics: any;
}>;
import type {
CollectorResult, Event, InstanceStatus, Logger,
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceRecord from "./InstanceRecord";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
/**
* Collection of controller plugin hooks
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
export class ControllerHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.save = this.newHook();
this.metrics = this.newHook();
this.shutdown = this.newHook();
this.instanceStatusChanged = this.newHook();
this.instanceConfigFieldChanged = this.newHook();
this.controllerConfigFieldChanged = this.newHook();
this.controlConnectionEvent = this.newHook();
this.hostConnectionEvent = this.newHook();
this.prepareHostDisconnect = this.newHook();
this.modPacksUpdated = this.newHook();
this.modsUpdated = this.newHook();
this.rolesUpdated = this.newHook();
this.playerEvent = this.newHook();
export default class BaseControllerPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
public info: PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
logger: Logger
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the controller saves data in memory to disk
*
@@ -41,7 +51,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* BaseControllerPlugin.onShutdown} have been invoked and all links have
* been disconnected.
*/
readonly save: lib.AsyncHook<[]>;
async onSaveData() { }
/**
* Called when the status of an instance changes
@@ -81,7 +91,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -93,7 +103,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called when the value of an instance config field changed.
@@ -107,8 +117,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
// eslint-disable-next-line max-len
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -125,12 +134,12 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called when the controller is shutting down
*/
readonly shutdown: lib.AsyncHook<[]>;
async onShutdown() { }
/**
* Called when an event on a host connection happens
@@ -165,8 +174,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
// eslint-disable-next-line max-len
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when an avent on a control connection happens
@@ -202,8 +210,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
// eslint-disable-next-line max-len
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when a host is preparing to disconnect from the controller
@@ -217,7 +224,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param connection -
* The connection to the host preparing to disconnect.
*/
readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
async onPrepareHostDisconnect(connection: HostConnection) { }
/**
* Called when one or more mod packs are updated
@@ -230,7 +237,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @param modPacks - Mod packs that updated.
*/
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
async onModPacksUpdated(modPacks: ModPack[]) { }
/**
* Called when one or more mod stored on the controller are updated
@@ -242,18 +249,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @param mods - Mods that updated.
*/
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
/**
* Called when one or more roles stored on the controller are updated
*
* Invoked when one or more roles have been added, updated or deleted.
*
* If a role has been deleted its `.isDeleted` property will be true.
*
* @param roles - Roles that updated.
*/
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
async onModsUpdated(mods: ModInfo[]) { }
/**
* Called when a player joins or leaves an instance
@@ -265,79 +261,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The instance it occured on.
* @param event - Information about the event.
*/
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseControllerPlugin {
onSaveData?(): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
}
/**
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseControllerPlugin {
constructor(
public info: lib.PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(controller.hooks.save, this.onSaveData);
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
attach(controller.hooks.metrics, this.onMetrics);
attach(controller.hooks.shutdown, this.onShutdown);
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
attach(controller.hooks.modsUpdated, this.onModsUpdated);
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
attach(controller.hooks.playerEvent, this.onPlayerEvent);
}
static fromContext(context: ControllerPluginContext) {
return new this(context.plugin, context.controller, context.metrics, context.logger);
}
detachHooks() {
this.controller.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
@@ -348,7 +272,7 @@ export class BaseControllerPlugin {
*
* @param event - Event to send
*/
broadcastEventToHosts<T>(event: lib.Event<T>) {
broadcastEventToHosts<T>(event: Event<T>) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
+37 -165
View File
@@ -1,15 +1,15 @@
import type WsServerConnector from "./WsServerConnector.js";
import type WsServerConnector from "./WsServerConnector";
import events from "events";
import * as lib from "@clusterio/lib";
import { logFilter, logger } from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection.js";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import * as routes from "./routes.js";
import Controller from "./Controller.js";
import BaseConnection from "./BaseConnection";
import User from "./User";
import UserRecord from "./UserRecord";
import * as routes from "./routes";
import Controller from "./Controller";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -48,16 +48,12 @@ export default class ControlConnection extends BaseConnection {
this._version = registerData.version;
// A resume installs a fresh socket, so re-apply the ignore flag or the
// dumper's own frames get echoed back into it and recurse forever.
for (let event of ["connect", "resume"] as const) {
this.connector.on(event, () => {
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
}
this.connector.on("connect", () => {
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
this.connector.on("close", () => {
if (this.logTransport) {
this._controller.clusterLogger.remove(this.logTransport);
logger.remove(this.logTransport);
this.logTransport = null;
}
if (this.ws_dumper) {
@@ -67,18 +63,16 @@ export default class ControlConnection extends BaseConnection {
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
this._controller.hooks.controlConnectionEvent.invoke(this, event);
for (let controllerPlugin of this._controller.plugins.values()) {
controllerPlugin.onControlConnectionEvent(this, event);
}
});
}
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(
lib.ControllerMarkRestartRequiredRequest, this.handleControllerMarkRestartRequiredRequest.bind(this)
);
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
@@ -90,7 +84,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
@@ -133,7 +126,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -175,7 +167,7 @@ export default class ControlConnection extends BaseConnection {
}
if (typeof permission === "string") {
this.user.checkPermission(permission as lib.PermissionName);
this.user.checkPermission(permission);
return;
}
@@ -195,15 +187,6 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.canRestart) {
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
}
const downgrade = await this._controller.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
throw new lib.RequestError(
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
`is older than running version ${runningVersion}. ` +
"Stop the controller before starting the older version manually."
);
}
this._controller.shouldRestart = true;
this._controller.stop();
}
@@ -212,26 +195,6 @@ export default class ControlConnection extends BaseConnection {
return this._controller.config.toRemote("control");
}
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this._controller.config.stageProp(
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
);
}
} else {
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
}
}
this._controller.config.commitStaging();
} finally {
this._controller.config.revertStaging();
}
}
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
}
@@ -295,9 +258,6 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
if ((request.config["instance.assigned_host"] ?? null) !== null) {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
const instanceConfig = new lib.InstanceConfig("controller");
if (request.cloneFromId) {
const baseInstance = this._controller.instances.get(request.cloneFromId);
@@ -320,43 +280,17 @@ export default class ControlConnection extends BaseConnection {
return instance.config.toRemote("control");
}
private static validateInstanceConfigSetField(fieldName: string) {
if (fieldName === "instance.assigned_host") {
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
if (fieldName === "instance.id") {
if (request.field === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new lib.RequestError("Setting instance.id is not supported");
}
}
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
ControlConnection.validateInstanceConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
instance.config.stageProp(
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
);
}
} else {
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
}
}
instance.config.commitStaging();
await this._controller.instanceConfigUpdated(instance);
} finally {
instance.config.revertStaging();
}
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
ControlConnection.validateInstanceConfigSetField(request.field);
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
await this._controller.instanceConfigUpdated(instance);
}
@@ -364,7 +298,6 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let { field, prop, value } = request;
ControlConnection.validateInstanceConfigSetField(field);
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
@@ -693,7 +626,6 @@ export default class ControlConnection extends BaseConnection {
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
@@ -722,16 +654,11 @@ export default class ControlConnection extends BaseConnection {
return null;
}
// Select the latest matching version for the mod pack's Factorio version
// Select the latest matching version
const release = releases.releases
.filter(info => (
versionRange.testVersion(info.version)
&& lib.normaliseMajorMinorVersion(
lib.normaliseSourceVersion(info.info_json.factorio_version)
) === factorioVersion
))
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
@@ -763,32 +690,16 @@ export default class ControlConnection extends BaseConnection {
continue;
}
// Get the current version range (dependencies can be promoted to a stricter requirement)
const versionRange =
dependencyRequirements.get(mod.name)
?? recommendedRequirements.get(mod.name)
?? optionalRequirements.get(mod.name)
?? new lib.ModVersionRange();
if (mod.required) {
// Add to required, remove from recommended and optional
dependencyRequirements.set(mod.name, versionRange);
recommendedRequirements.delete(mod.name);
optionalRequirements.delete(mod.name);
} else if (mod.recommended) {
// If not required, add to recommended and remove from optional
if (!dependencyRequirements.has(mod.name)) {
recommendedRequirements.set(mod.name, versionRange);
// 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);
}
} else if (
!dependencyRequirements.has(mod.name)
&& !recommendedRequirements.has(mod.name)
) {
// If not required or recommended, add to optional
optionalRequirements.set(mod.name, versionRange);
}
// Update the version range if needed
@@ -797,7 +708,6 @@ export default class ControlConnection extends BaseConnection {
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
continue;
}
}
@@ -811,11 +721,7 @@ export default class ControlConnection extends BaseConnection {
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (
info.name === mod.name
&& info.factorioVersion === factorioVersion
&& versionRange.testVersion(info.version)
))
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
@@ -849,9 +755,9 @@ export default class ControlConnection extends BaseConnection {
}
}
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
@@ -875,16 +781,6 @@ export default class ControlConnection extends BaseConnection {
this.updateLogSubscriptions();
}
/** Stop following logs if the user no longer has permission to */
checkLogPermission() {
try {
this.user.checkPermission("core.log.follow");
} catch {
this.logSubscriptions = { all: false, controller: false, hostIds: [], instanceIds: [] };
this.updateLogSubscriptions();
}
}
updateLogSubscriptions() {
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
if (all || controller || hostIds.length || instanceIds.length) {
@@ -1090,11 +986,6 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.users.deleteUser(user);
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.id === user.id) {
controlConnection.connector.terminate();
}
}
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -1310,10 +1201,6 @@ export default class ControlConnection extends BaseConnection {
return usersToSend;
}
async handleControllerMarkRestartRequiredRequest() {
this._controller.config.restartRequired = true;
}
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
@@ -1332,35 +1219,18 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
this._controller.config.restartRequired = true;
}
}
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
if (!this._controller.config.get("controller.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
return await lib.handlePluginInstall(request.pluginPackage);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.loadedPlugins.has(pluginInfo),
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
if (this.ws_dumper) {
return; // Already dumping
}
this.ws_dumper = data => {
if (this.connector.connected) {
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
@@ -1370,3 +1240,5 @@ export default class ControlConnection extends BaseConnection {
this._controller.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
+95 -213
View File
@@ -1,37 +1,34 @@
import type winston from "winston";
import { BlockList, type AddressInfo, isIPv4, isIPv6 } from "net";
import type { ControllerArgs } from "../controller.js";
import type ControlConnection from "./ControlConnection.js";
import type { ControllerArgs } from "../controller";
import express, { type Request, type Response, type NextFunction, type Application } from "express";
import type { Static } from "@sinclair/typebox";
import finalhandler from "finalhandler";
import compression from "compression";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import http from "http";
import https from "https";
import jwt from "jsonwebtoken";
import path from "path";
import semver from "semver";
import stream from "stream";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as lib from "@clusterio/lib";
import { logger, Summary, Gauge } from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import HttpCloser from "./HttpCloser.js";
import InstanceRecord from "./InstanceRecord.js";
import * as metrics from "./metrics.js";
import * as routes from "./routes.js";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import UserManager from "./UserManager.js";
import WsServer from "./WsServer.js";
import HostRecord from "./HostRecord.js";
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
import ControllerRouter from "./ControllerRouter.js";
import InstanceManager from "./InstanceManager.js";
import HttpCloser from "./HttpCloser";
import InstanceRecord from "./InstanceRecord";
import * as metrics from "./metrics";
import * as routes from "./routes";
import User from "./User";
import UserRecord from "./UserRecord";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostRecord from "./HostRecord";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -71,10 +68,8 @@ export default class Controller {
httpsServer: https.Server | null = null;
httpsServerCloser: HttpCloser | null = null;
/** Hooks which plugins can attach to */
hooks = new ControllerHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseControllerPlugin> = new Map();
/** WebSocket server */
wsServer: WsServer;
@@ -84,7 +79,7 @@ export default class Controller {
private _events = new events.EventEmitter<ControllerEvents>();
/** Event subscription controller */
subscriptions: lib.SubscriptionController;
subscriptions = new lib.SubscriptionController();
/** User manager for the cluster, also contains access to the user records */
users: UserManager;
/** Instance manager for the cluster, also contains access to the user records */
@@ -126,31 +121,9 @@ export default class Controller {
}
});
// Cache for the latest stable/experimental factorio releases. Persisted to
// disk so that release channel targets can still be resolved when
// factorio.com is unavailable.
latestReleases = new lib.ValueCache(async () => {
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
try {
const releases = await lib.fetchLatestReleases();
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
return releases;
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
try {
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
} catch (readErr: any) {
if (readErr.code !== "ENOENT") {
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
}
return {};
}
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.mkdir(databaseDirectory, { recursive: true });
await fs.ensureDir(databaseDirectory);
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
@@ -175,12 +148,6 @@ export default class Controller {
InstanceRecord.fromJSON.bind(InstanceRecord),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
// Save plugin versions written while loading
for (const instance of instances.values()) {
if (instance.config.dirty) {
instances.set(instance);
}
}
const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "saves.json"),
@@ -206,7 +173,7 @@ export default class Controller {
).bootstrap());
let modsDirectory = config.get("controller.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
await fs.ensureDir(modsDirectory);
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
// Add default mod packs
@@ -280,7 +247,6 @@ export default class Controller {
});
// Handle subscriptions for all internal properties
this.subscriptions = new lib.SubscriptionController(config.get("controller.max_subscription_filters"));
this.subscriptions.handle(lib.SystemInfoUpdateEvent, this.handleSystemInfoSubscription.bind(this));
this.subscriptions.handle(lib.HostUpdatesEvent, this.handleHostSubscription.bind(this));
this.subscriptions.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsSubscription.bind(this));
@@ -302,11 +268,6 @@ export default class Controller {
this.users.records.on("update", this.usersUpdated.bind(this));
}
get authSecret() {
// Auth secret is never null after startup
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
}
async start(args: ControllerArgs) {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
@@ -356,7 +317,7 @@ export default class Controller {
// Start webpack development server if enabled
if (args.dev || args.devPlugin) {
await this._startDevServer(args);
this._startDevServer(args);
}
this.config.on("fieldChanged", (field, curr, prev) => {
@@ -366,10 +327,8 @@ export default class Controller {
this.onSystemMetricsIntervalChanged();
} else if (field === "controller.trusted_proxies") {
this.trustedProxies = this.parseTrustedProxies();
} else if (field === "controller.static_url") {
this.app.locals.staticRoot = this.config.get("controller.static_url");
}
this.hooks.controllerConfigFieldChanged.invoke(field, curr, prev);
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
});
// Make sure we're actually going to listen on a port
@@ -394,7 +353,7 @@ export default class Controller {
Controller.addAppRoutes(this.app, this.pluginInfos);
if (!args.dev) {
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
let manifestPath = path.join(__dirname, "..", "..", "web", "manifest.json");
let manifest = await Controller.loadJsonObject(manifestPath);
if (!manifest["main.js"]) {
@@ -402,24 +361,22 @@ export default class Controller {
}
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
}
this.app.locals.staticRoot = this.config.get("controller.static_url");
// Load plugins
await this.loadPlugins();
// Log all express errors from middleware or routes (app.use is missing the type overload)
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
const url = lib.redactUrl(req.originalUrl);
logger.log({
level: "error",
message: "Error while handling HTTP" +
`${req.httpVersion} ${req.method} ${url}:\n${err.stack}`,
`${req.httpVersion} ${req.method} ${req.originalUrl}:\n${err.stack}`,
meta: {
method: req.method,
url,
url: req.originalUrl,
httpVersion: req.httpVersion,
headers: Controller.loggableHeaders(req.headers),
query: lib.redactQuery(req.query),
headers: req.headers,
query: req.query,
},
});
@@ -470,13 +427,12 @@ export default class Controller {
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
const webpack = (await import("webpack")).default;
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfigs = [];
if (args.dev) {
// @ts-expect-error this file is run from a different folder
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
}
if (args.devPlugin) {
let devPlugins = new Map();
@@ -485,10 +441,7 @@ export default class Controller {
if (!info) {
throw new lib.StartupError(`No plugin named ${name}`);
}
const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
let config = (await import(
path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
)).default({});
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
}
@@ -553,7 +506,7 @@ export default class Controller {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
}
await this.hooks.shutdown.invoke();
await lib.invokeHook(this.plugins, "onShutdown");
await this.wsServer.stop();
@@ -643,10 +596,8 @@ export default class Controller {
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = fileURLToPath(
import.meta.resolve("@clusterio/controller/package.json"),
);
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -662,44 +613,20 @@ export default class Controller {
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
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}`);
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("controller.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/controller/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) {
logger.warn(
`Unable to compare running controller version ${runningVersion} ` +
`with installed version ${installedVersion}.`
);
return null;
}
if (semver.lt(installedVersion, runningVersion)) {
return { installedVersion, runningVersion };
}
} catch (err: any) {
logger.warn(`Failed to check controller version before restart:\n${err.stack ?? err.message}`);
}
return null;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -743,8 +670,9 @@ export default class Controller {
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
this.hooks.save.invoke(),
]);
await lib.invokeHook(this.plugins, "onSaveData");
}
static migrateSystems(rawJson: unknown[]): Static<typeof lib.SystemInfo.jsonSchema>[] {
@@ -808,11 +736,8 @@ export default class Controller {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
// All other versions are kept verbatim so they still match the mod's file name.
if (lib.isPartialVersion(mod.version)) {
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
}
// 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;
});
@@ -828,7 +753,7 @@ export default class Controller {
}
return permission;
});
return lib.Role.pluginMigrations.reduce(json);
return json;
});
}
@@ -851,7 +776,7 @@ export default class Controller {
try {
manifest = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
} catch (err: any) {
if (throwOnMissing || err.code !== "ENOENT") {
if (!throwOnMissing && err.code !== "ENOENT") {
throw err;
}
}
@@ -885,7 +810,7 @@ export default class Controller {
);
}
static addAppRoutes(app: Application, pluginInfos: lib.PluginNodeEnvInfo[]) {
static addAppRoutes(app: Application, pluginInfos: any[]) {
app.use((req: Request, res: Response, next) => {
const startNs = process.hrtime.bigint();
stream.finished(res, () => {
@@ -896,19 +821,18 @@ export default class Controller {
const endNs = process.hrtime.bigint();
const durationMs = (Number(endNs - startNs) / 1e6);
endpointDurationSummary.labels(routePath).observe(durationMs / 1e3);
const url = lib.redactUrl(req.originalUrl);
logger.log({
level: "http",
message: `HTTP${req.httpVersion} ${req.method} ${url}` +
message: `HTTP${req.httpVersion} ${req.method} ${req.originalUrl}` +
` (Status ${res.statusCode} in ${durationMs}ms)`,
meta: {
method: req.method,
url,
url: req.originalUrl,
statusCode: res.statusCode,
responseTime: durationMs,
httpVersion: req.httpVersion,
headers: Controller.loggableHeaders(req.headers),
query: lib.redactQuery(req.query),
headers: req.headers,
query: req.query,
},
});
});
@@ -919,7 +843,7 @@ export default class Controller {
// Set folder to serve static content from (the website)
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
app.use("/static",
express.static(path.join(import.meta.dirname, "..", "..", "web", "static"), staticOptions)
express.static(path.join(__dirname, "..", "..", "web", "static"), staticOptions)
);
app.use("/static", express.static("static", staticOptions)); // Used for data export files
@@ -935,7 +859,9 @@ export default class Controller {
app.get(route, Controller.serveWeb(route));
}
app.use("/static", express.static(pluginInfo.webStaticPath, staticOptions));
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web", "static");
app.use("/static", express.static(webPath, staticOptions));
}
}
@@ -944,9 +870,6 @@ export default class Controller {
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
);
@@ -960,7 +883,10 @@ export default class Controller {
* @param hostId - ID of host to generate a token for.
*/
generateHostToken(hostId: number): string {
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
return jwt.sign(
{ aud: "host", host: hostId },
Buffer.from(this.config.get("controller.auth_secret"), "base64")
);
}
hostsUpdated(hosts: HostRecord[]) {
@@ -969,9 +895,6 @@ export default class Controller {
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const hosts = [...this.hosts.values()].filter(
host => host.updatedAtMs > request.lastRequestTimeMs,
).map(host => host.toHostDetails());
@@ -1019,9 +942,6 @@ export default class Controller {
}
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const instances = [...this.instances.values()].filter(
instance => instance.updatedAtMs > request.lastRequestTimeMs,
).map(instance => instance.toInstanceDetails());
@@ -1033,25 +953,17 @@ export default class Controller {
}
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const saves = [...this.saves.values()].filter(
save => save.updatedAtMs > request.lastRequestTimeMs
);
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
}
modPacksUpdated(modPacks: lib.ModPack[]) {
this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks));
this.hooks.modPacksUpdated.invoke(modPacks);
lib.invokeHook(this.plugins, "onModPacksUpdated", modPacks);
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const modPacks = [...this.modPacks.values()].filter(
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1061,13 +973,10 @@ export default class Controller {
modsUpdated(mods: lib.ModInfo[]) {
// ModStore sets updatedAtMs for mods
this.subscriptions.broadcast(new lib.ModUpdatesEvent(mods));
this.hooks.modsUpdated.invoke(mods);
lib.invokeHook(this.plugins, "onModsUpdated", mods);
}
async handleModSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const mods = [...this.modStore.files.values()].filter(
mod => mod.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1079,9 +988,6 @@ export default class Controller {
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1096,41 +1002,39 @@ export default class Controller {
userPermissionsUpdated(user: Readonly<User>) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
this._controlPermissionsUpdated(controlConnection);
controlConnection.send(
new lib.AccountUpdateEvent([...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})))
);
}
}
}
_controlPermissionsUpdated(controlConnection: ControlConnection) {
const user = controlConnection.user;
this.subscriptions.checkLinkPermissions(controlConnection, user);
controlConnection.checkLogPermission();
controlConnection.send(
new lib.AccountUpdateEvent([...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})))
);
}
rolesUpdated(roles: lib.Role[]) {
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
this.hooks.rolesUpdated.invoke(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)) {
this._controlPermissionsUpdated(controlConnection);
controlConnection.send(
new lib.AccountUpdateEvent(
[...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
}))
)
);
}
}
}
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1138,18 +1042,12 @@ export default class Controller {
}
async loadPlugins() {
const context = { metrics, logger, controller: this };
for (let pluginInfo of this.pluginInfos) {
try {
const manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
const resolvedUrl = import.meta.resolve(manifestPath);
const resolvedPath = resolvedUrl.startsWith("file://") ? fileURLToPath(resolvedUrl) : resolvedUrl;
pluginInfo.manifest = await Controller.loadJsonObject(resolvedPath, true);
let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
pluginInfo.manifest = await Controller.loadJsonObject(require.resolve(manifestPath), true);
} catch (err) {
if (lib.pluginNeedsWebBuild(pluginInfo)) {
const stack = (err as Error).stack ?? (err as Error).message;
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}\n${stack}`);
}
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
@@ -1161,16 +1059,20 @@ export default class Controller {
continue;
}
let ControllerPluginClass = BaseControllerPlugin;
try {
await lib.loadPlugin(
pluginInfo,
"controller",
context,
"ControllerPlugin",
BaseControllerPlugin,
);
if (pluginInfo.controllerEntrypoint) {
ControllerPluginClass = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.controllerEntrypoint),
"ControllerPlugin",
BaseControllerPlugin,
);
}
this.loadedPlugins.add(pluginInfo);
let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics as any, logger);
await controllerPlugin.init();
this.plugins.set(pluginInfo.name, controllerPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
@@ -1236,23 +1138,7 @@ export default class Controller {
}
/**
* Copy of request headers with credentials masked for logging
*
* @param headers - headers of the incoming request.
* @returns headers safe to write to the log.
*/
static loggableHeaders(headers: http.IncomingHttpHeaders) {
const result = { ...headers };
for (const name of ["authorization", "cookie", "proxy-authorization", "x-access-token"]) {
if (result[name] !== undefined) {
result[name] = "[redacted]";
}
}
return result;
}
/**
* Serves the web interface with the root path set appropriately
* Servers the web interface with the root path set apropriately
*
* @param route - route the interface is served under.
* @returns Experess.js route handler.
@@ -1266,20 +1152,16 @@ export default class Controller {
return function(req: Request, res: Response, next: NextFunction) {
let depth = routeDepth + Number(req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
let staticRoot = webRoot;
let mainBundle: string = "";
if (res.app.locals.mainBundle) {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
mainBundle = stats.toJson().assetsByChunkName["main"][0];
mainBundle = stats.toJson().assetsByChunkName["main"];
}
mainBundle = routes.stripStaticPrefix(mainBundle);
fs.readFile(
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
"utf8",
).then((content) => {
fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
+5 -5
View File
@@ -1,10 +1,10 @@
import type Controller from "./Controller.js";
import type BaseConnection from "./BaseConnection.js";
import type Controller from "./Controller";
import type BaseConnection from "./BaseConnection";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import HostConnection from "./HostConnection.js";
import ControlConnection from "./ControlConnection.js";
import HostConnection from "./HostConnection";
import ControlConnection from "./ControlConnection";
const { logger } = lib;
export default class ControllerRouter {
constructor(
+25 -78
View File
@@ -1,17 +1,14 @@
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection.js";
import InstanceRecord from "./InstanceRecord.js";
import HostRecord from "./HostRecord.js";
import BaseConnection from "./BaseConnection";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
// How long log message warnings are suppressed for after one is logged.
const logMessageWarnIntervalMs = 5 * 60 * 1000;
/**
* Represents the connection to a host
*
@@ -21,8 +18,6 @@ const logMessageWarnIntervalMs = 5 * 60 * 1000;
export default class HostConnection extends BaseConnection {
private info: HostRecord;
plugins: Map<string, string>;
// Shared by all log message warnings, a misbehaving host could log in a loop.
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
constructor(
registerData: lib.RegisterHostData,
@@ -52,7 +47,12 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
}
});
}
this.connector.on("close", () => {
@@ -66,7 +66,7 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instances.records.setMany(instances);
@@ -143,7 +143,7 @@ export default class HostConnection extends BaseConnection {
}
async prepareDisconnect() {
await this._controller.hooks.prepareHostDisconnect.invoke(this);
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
return await super.prepareDisconnect();
}
@@ -216,7 +216,7 @@ export default class HostConnection extends BaseConnection {
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
@@ -256,7 +256,9 @@ export default class HostConnection extends BaseConnection {
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
}
continue;
}
@@ -272,7 +274,7 @@ export default class HostConnection extends BaseConnection {
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
@@ -300,37 +302,7 @@ export default class HostConnection extends BaseConnection {
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
}
// A host may only report on instances assigned to it. Otherwise any host
// could speak for an instance on a different host. See
// handleInstanceStatusChangedEvent.
private _assignedInstance(instanceId: number) {
const instance = this._controller.instances.get(instanceId);
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
}
// Host connections live for days, so the suppression expires instead of
// latching for the life of the connection.
private _warnLogMessage(message: string) {
const now = Date.now();
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
return;
}
this._lastLogMessageWarnMs = now;
logger.warn(message);
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
// Otherwise any host could inject save records for, or wipe the save
// list of, an instance on a different host.
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
return;
}
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
logger.warn(`Got bogus save updates from host id ${this.id}`);
return;
}
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
@@ -355,33 +327,8 @@ export default class HostConnection extends BaseConnection {
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
// Fields beyond level and message survive schema validation, so the
// instance a log line claims to come from is whatever the host sent.
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
if (info.instance_id !== undefined) {
const instance = this._assignedInstance(info.instance_id);
if (!instance) {
// Demote to a host level line instead of dropping it. The host did
// log this, it just does not get to pin it on someone else's
// instance, and a host racing a reassignment still gets heard.
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
delete info.instance_id;
delete info.instance_name;
} else {
// The name the host sent may be stale or made up, ours is authoritative.
const name = instance.config.get("instance.name");
if (info.instance_name !== name) {
this._warnLogMessage(
`Got log message for instance id ${info.instance_id} named ` +
`${info.instance_name} which is named ${name} on the controller`
);
info.instance_name = name;
}
}
}
this._controller.clusterLogger.log({
...info,
...event.info,
host_id: this.id,
host_name: this.info.name,
});
@@ -403,7 +350,7 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.type,
name: event.name,
reason: event.reason,
@@ -420,7 +367,7 @@ export default class HostConnection extends BaseConnection {
user.set("isAdmin", event.admin);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
@@ -438,11 +385,11 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
reason: event.reason,
resaon: event.reason,
});
}
@@ -455,7 +402,7 @@ export default class HostConnection extends BaseConnection {
user.set("isWhitelisted", event.whitelisted);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
+20 -16
View File
@@ -1,8 +1,8 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller.js";
import type HostConnection from "./HostConnection.js";
import InstanceRecord from "./InstanceRecord.js";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
@@ -83,7 +83,12 @@ export default class InstanceManager {
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
}
private async _notifyConfigFieldChanged(
@@ -92,7 +97,14 @@ export default class InstanceManager {
curr: unknown,
prev: unknown,
): Promise<void> {
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
}
/**
@@ -208,17 +220,9 @@ export default class InstanceManager {
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
try {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} catch (err) {
// Leave unassigned so the assign can be retried
instance.config.set("instance.assigned_host", null);
instance.status = "unassigned";
this.records.set(instance);
throw err;
}
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} else {
instance.status = "unassigned";
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, PermissionName, permissions,
PermissionError, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord.js";
import UserRecord from "./UserRecord";
import { Static } from "@sinclair/typebox";
/**
@@ -126,7 +126,7 @@ export default class User extends UserRecord implements IUser {
this.saveRecord();
}
checkPermission(permission: PermissionName) {
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
+5 -8
View File
@@ -1,10 +1,10 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import User from "./User";
import UserRecord from "./UserRecord";
/**
* Access layer for users on the controller.
@@ -119,9 +119,7 @@ export default class UserManager {
roles.add(defaultRoleId);
}
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
const tokenValidAfter = Math.floor(Date.now() / 1000);
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
return user;
}
@@ -153,8 +151,7 @@ export default class UserManager {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: user.id },
// auth secret is never null after startup
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
);
}
+37 -28
View File
@@ -1,18 +1,18 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type Controller from "./Controller.js";
import type Controller from "./Controller";
import jwt from "jsonwebtoken";
import WebSocket, { WebSocketServer } from "ws";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
import ControlConnection from "./ControlConnection.js";
import HostConnection from "./HostConnection.js";
import WsServerConnector from "./WsServerConnector.js";
import ControlConnection from "./ControlConnection";
import HostConnection from "./HostConnection";
import WsServerConnector from "./WsServerConnector";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
import { version as packageVersion } from "../package.json";
const wsMessageCounter = new lib.Counter(
"clusterio_controller_websocket_message_total",
@@ -45,12 +45,12 @@ export default class WsServer {
sessionAud: string = `session-${Date.now()}`;
nextSessionId: number = 1;
nextControlId: number = 1;
wss: WebSocketServer;
wss: WebSocket.Server;
constructor(controller: Controller) {
this.controller = controller;
this.wss = new WebSocketServer({
this.wss = new WebSocket.Server({
noServer: true,
path: "/api/socket",
});
@@ -116,13 +116,6 @@ export default class WsServer {
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
// ws emits errors like invalid frames on the socket, and an error with
// no listener crashes the controller. The connector adds its own
// listener, but only once the handshake has completed.
socket.on("error", (err: Error) => {
logger.verbose(`WsServer | error on connection from ${this.remoteAddr(req)}: ${err.message}`);
});
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message: WebSocket.RawData) => {
@@ -142,11 +135,11 @@ export default class WsServer {
// Start connection handshake.
let loadedPlugins: {[key:string]: string} = {};
for (const plugin of this.controller.loadedPlugins) {
loadedPlugins[plugin.name] = plugin.version;
for (let [name, plugin] of this.controller.plugins) {
loadedPlugins[name] = plugin.info.version;
}
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
this.attachHandler(socket, req);
}
@@ -230,11 +223,13 @@ ${err.stack}`
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(sessionId);
@@ -252,8 +247,13 @@ ${err.stack}`
}
try {
// migrate: allow pre-rename tokens issued to hosts before alpha-14
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -295,11 +295,10 @@ ${err.stack}`
logger.info(
`WsServer | registered host ${data.id} using version ${data.version}`
);
if (data.version !== packageConfig.version) {
if (data.version !== packageVersion) {
logger.warn(
`Host ${data.id} connected using version ${data.version} which does not match the ` +
`version of the controller is currently running (${packageConfig.version}). ` +
"It may not work as expected."
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
);
}
@@ -325,7 +324,12 @@ ${err.stack}`
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -372,7 +376,12 @@ ${err.stack}`
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+11 -11
View File
@@ -1,10 +1,10 @@
import { type WebSocket } from "ws";
import type WebSocket from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
/**
@@ -21,10 +21,8 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
private _sessionId: number,
private _sessionTimeout: number,
public _heartbeatInterval: number,
maxSendBufferSize = 0,
) {
super(new lib.Address(lib.Address.controller, 0), dst);
this.maxSendBufferSize = maxSendBufferSize;
// The following states are used in the server connector
// closed: Connection is closed
@@ -51,7 +49,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param account - account data to provide to control connection
*/
ready(
socket: WebSocket,
socket: WebSocket.WebSocket,
src: lib.Address,
sessionToken: string,
account: lib.AccountDetails | undefined
@@ -82,7 +80,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param lastSeq - The last message the client received.
*/
continue(
socket: WebSocket,
socket: WebSocket.WebSocket,
lastSeq: number
) {
@@ -108,17 +106,14 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
logger.verbose(
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
);
for (let message of this._sendBuffer) {
this._socket.send(message.text);
this._socket.send(JSON.stringify(message));
}
this.emit("resume");
}
_timedOut() {
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
logger.verbose("Connector | Connection timed out");
this._close();
}
@@ -144,6 +139,11 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
}
});
this._socket.on("error", (err: any) => {
// It's assumed that close is always called by ws
logger.verbose("Connector | Error:", err);
});
this._socket.on("open", () => {
logger.verbose("Connector | Open");
});
+58 -84
View File
@@ -1,10 +1,10 @@
import type { Application, Request, Response } from "express";
import Controller from "./Controller.js";
import type Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import JSZip from "jszip";
import jwt from "jsonwebtoken";
import path from "path";
@@ -12,7 +12,7 @@ import nodeStream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
const finished = util.promisify(nodeStream.finished);
@@ -22,10 +22,8 @@ declare global {
export interface Locals {
controller: Controller,
mainBundle: string,
staticRoot: string | null,
devPlugins: Map<string, number>,
streams: Map<string, ProxyStream>,
pendingMetrics?: Promise<string>,
streams: Map<string, ProxyStream>
}
}
}
@@ -45,10 +43,12 @@ function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceRe
}
}
// Collect metrics from the controller, its plugins and all connected hosts
async function gatherMetrics(controller: Controller) {
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response, next: any) {
const controller: Controller = req.app.locals.controller;
let results: lib.CollectorResult[] = [];
let pluginResults = await controller.hooks.metrics.collect();
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -98,46 +98,18 @@ async function gatherMetrics(controller: Controller) {
results.push(lib.deserializeResult(result));
}
return await lib.exposition(results);
}
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response) {
try {
res.locals.user.checkPermission("core.controller.metrics");
} catch (err: any) {
res.status(403).json({ request_errors: [err.message] });
return;
}
// Concurrent scrapes share one gather so hosts see at most one
// metrics request at a time regardless of how many clients poll.
const locals = req.app.locals;
if (!locals.pendingMetrics) {
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
locals.pendingMetrics = undefined;
});
}
const text = await locals.pendingMetrics;
let text = await lib.exposition(results);
res.set("Content-Type", lib.expositionContentType);
res.send(text);
}
/**
* Removes the static/ prefix webpack adds to bundle paths so they can be
* resolved relative to staticRoot.
*/
export function stripStaticPrefix(bundlePath: string) {
return bundlePath.replace(/^static\//, "");
}
function getPlugins(req: Request, res: Response) {
let plugins: lib.PluginWebApi[] = [];
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
let loaded = req.app.locals.controller.plugins.has(name);
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.
@@ -145,21 +117,18 @@ function getPlugins(req: Request, res: Response) {
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
web.main = stats.toJson().assetsByChunkName[name][0];
web.main = stats.toJson().assetsByChunkName[name];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
} else {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
if (web.main) {
web.main = stripStaticPrefix(web.main);
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
}
res.send(plugins);
@@ -171,28 +140,20 @@ function getClusterName(req: Request, res: Response) {
res.json({ name: clusterName });
}
// Reads the access token from X-Access-Token or Authorization: Bearer
function getAccessToken(req: Request) {
const token = req.header("x-access-token");
if (token) {
return token;
}
const authorization = req.header("authorization");
if (authorization && /^Bearer /i.test(authorization)) {
return authorization.slice("Bearer ".length).trim();
}
return undefined;
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = getAccessToken(req);
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -212,14 +173,19 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
function validateUserToken(req: Request, res: Response, next: any) {
let token = getAccessToken(req);
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -275,13 +241,12 @@ async function uploadExport(req: Request, res: Response) {
"export/settings.json",
"export/prototypes.json",
"export/locale.json",
"export/defines.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
let settingPrototypes: lib.ExportSettings = {};
let settingPrototypes = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
@@ -349,7 +314,7 @@ export async function createProxyStream(app: Application): Promise<ProxyStream>
return stream;
}
async function putStream(req: Request<{ id: string }>, res: Response) {
async function putStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
@@ -388,7 +353,7 @@ function startStream(res: Response, stream: ProxyStream) {
clearTimeout(stream.timeout);
}
async function getStream(req: Request<{ id: string }>, res: Response) {
async function getStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
@@ -587,16 +552,30 @@ async function uploadMod(req: Request, res: Response) {
}
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
const filePath = path.join(modsDirectory, filename);
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream;
try {
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
checkModName(modInfo.filename);
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
req.app.locals.controller.modStore.addMod(modInfo);
mods.push(modInfo.toJSON());
@@ -606,13 +585,15 @@ async function uploadMod(req: Request, res: Response) {
stream.resume();
// Attempt to clean up.
writeStream.destroy();
if (writeStream) {
writeStream.destroy();
}
try {
await fs.unlink(tempFilePath);
await fs.unlink(path.join(modsDirectory, tempFilename));
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
}
}
}
@@ -685,22 +666,15 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics",
validateUserToken,
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
);
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)
);
app.put("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
);
app.get("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => getStream(req, res).catch(next)
);
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
@@ -721,12 +695,12 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+1 -1
View File
@@ -29,7 +29,7 @@
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
<script>
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
function scriptError(err) {
console.error(err);
let bootstrapMsg = document.getElementById("bootstrap-msg");
File renamed without changes.
+1 -2
View File
@@ -2,5 +2,4 @@
Installer for Clusterio.
Install the latest [Node.js LTS release](https://nodejs.org/en/download) before running the installer.
See [the Clusterio installation guide](https://github.com/clusterio/clusterio#installation) for platform-specific instructions.
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
+248 -65
View File
@@ -1,19 +1,21 @@
#!/usr/bin/env node
import child_process from "node:child_process";
import fs from "node:fs/promises";
import inquirer from "inquirer";
import os from "node:os";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import yargs from "yargs";
"use strict";
const child_process = require("child_process");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
import { levels, logger, setLogLevel } from "./logging.js";
import { copyPluginTemplates } from "./template.js";
const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
import { escapeArg } from "./escape_arg.js";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
@@ -30,8 +32,8 @@ if (process.platform === "win32") {
const availablePlugins = [
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
@@ -41,6 +43,9 @@ const availablePlugins = [
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const windowsHeadlessDownloadUrl = "https://factorio-repackager.fly.dev/get-download/latest/headless/win64";
const headlessDownloadPlatforms = new Set(["linux", "win32"]);
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
@@ -51,6 +56,10 @@ const factorioLocations = {
],
};
function supportsHeadlessDownload(platform = process.platform) {
return headlessDownloadPlatforms.has(platform);
}
class LineSplitter extends stream.Transform {
constructor(options) {
@@ -95,33 +104,21 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function pathExists(filePath) {
try {
await fs.access(filePath);
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
}
return true;
}
async function safeOutputFile(file, data, options={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(temporary, data, { ...options, flush: true });
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
const command = [cmd, ...args.map(escapeArg)].join(" ");
logger.verbose(`executing ${command}`);
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(
command,
cmd,
escaped,
{
shell: true,
@@ -146,21 +143,21 @@ async function execFile(cmd, args) {
async function execController(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
}
async function execHost(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
@@ -231,10 +228,10 @@ async function migrateRename(args) {
}
async function migrateConfig(source, destination) {
if (await pathExists(source) && !await pathExists(destination)) {
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
), { mode: 0o600 });
));
logger.info(`Migrated ${source} to ${destination}`);
}
}
@@ -243,7 +240,7 @@ async function migrateRename(args) {
await migrateConfig("config-slave.json", "config-host.json");
let instancesFile = path.join("database", "instances.json");
if (await pathExists(instancesFile)) {
if (await fs.pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
@@ -251,7 +248,7 @@ async function migrateRename(args) {
}
let usersFile = path.join("database", "users.json");
if (await pathExists(usersFile)) {
if (await fs.pathExists(usersFile)) {
let users = JSON.parse(await fs.readFile(usersFile));
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
@@ -268,7 +265,7 @@ async function migrateRename(args) {
async function migrateLog(inputFile, outputFile) {
let lineStream = new LineSplitter({ readableObjectMode: true });
let fileStream = (await fs.open(inputFile)).createReadStream();
let fileStream = fs.createReadStream(inputFile);
fileStream.pipe(lineStream);
let lines = [];
@@ -288,7 +285,7 @@ async function migrateRename(args) {
}
async function migrateLogsDir(inputLogs, outputLogs) {
if (await pathExists(inputLogs)) {
if (await fs.pathExists(inputLogs)) {
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
for (let logFile of await fs.readdir(inputLogs)) {
if (!logFile.endsWith(".log")) {
@@ -296,7 +293,7 @@ async function migrateRename(args) {
}
let inputFile = path.join(inputLogs, logFile);
let outputFile = path.join(outputLogs, rename(logFile));
if (!await pathExists(outputFile)) {
if (!await fs.pathExists(outputFile)) {
await migrateLog(inputFile, outputFile);
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
}
@@ -307,16 +304,16 @@ async function migrateRename(args) {
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
if (
await pathExists(path.join("logs", "cluster"))
&& !await pathExists(path.join("logs", "cluster-prerename"))
await fs.pathExists(path.join("logs", "cluster"))
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
) {
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
}
if (
await pathExists("sharedMods")
&& !await pathExists("mods")
await fs.pathExists("sharedMods")
&& !await fs.pathExists("mods")
) {
logger.info("Moving sharedMods/ to mods/");
await fs.rename("sharedMods", "mods");
@@ -338,8 +335,8 @@ async function migrateRename(args) {
await execFile(`npm${scriptExt}`, ["update"]);
}
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
if (hasRunMaster || hasRunSlave) {
logger.info("Writing run scripts");
let mode = "standalone";
@@ -364,7 +361,7 @@ async function migrateRename(args) {
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
await fs.outputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -392,7 +389,31 @@ async function installClusterio(mode, plugins) {
throw new InstallError(`Failed to install: ${err.message}`);
}
// Controller, host and ctl discover installed plugins when loading their configuration.
if (plugins.length) {
logger.info("Setting up plugins");
let pluginList;
try {
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
} catch (err) {
if (err.code === "ENOENT") {
pluginList = new Map();
} else {
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
}
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
pluginList.set(pluginInfo.name, plugin);
}
}
try {
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
}
}
async function groupIdToName(gid) {
@@ -629,10 +650,26 @@ async function inquirerMissingArgs(args) {
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
if (supportsHeadlessDownload()) {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "Automatically download latest headless Factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -641,7 +678,7 @@ async function inquirerMissingArgs(args) {
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Use local factorio directory", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
@@ -657,7 +694,7 @@ async function inquirerMissingArgs(args) {
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.mkdir("factorio", { recursive: true });
await fs.ensureDir("factorio");
}
}
@@ -724,8 +761,136 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.ensureDir(tmpFactorioDir);
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function getFactorioVersion(factorioDir) {
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
const changelog = await fs.readFile(changelogPath, "utf8");
for (const line of changelog.split(/\r?\n/)) {
const index = line.indexOf(":");
if (index === -1) {
continue;
}
if (line.slice(0, index).trim().toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
throw new Error(`Unable to detect Factorio version from ${changelogPath}`);
}
async function findFactorioRoot(rootDir) {
const entries = await fs.readdir(rootDir);
const factorioRoot = entries.find(entry => entry === "factorio" || entry === "Factorio")
?? (entries.length === 1 ? entries[0] : null);
if (!factorioRoot) {
throw new Error(`Unable to locate extracted Factorio directory in ${rootDir}`);
}
return path.join(rootDir, factorioRoot);
}
async function downloadWindowsServer() {
let res = await fetch(windowsHeadlessDownloadUrl);
if (res.status === 202) {
throw new Error("Latest Windows headless package is not ready yet, try again in a few minutes.");
}
if (!res.ok) {
throw new Error(`Failed to fetch ${windowsHeadlessDownloadUrl}: HTTP ${res.status} ${res.statusText}`);
}
if (!res.body) {
throw new Error("Windows headless download did not include an archive payload");
}
const tmpDir = path.join("temp", "create-temp");
const archivePath = path.join(tmpDir, "factorio-headless-win64.zip");
const tmpArchivePath = `${archivePath}.tmp`;
const tmpExtractDir = path.join(tmpDir, "win64-extract");
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Windows headless release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
const expandArchiveCommand =
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' ` +
`-DestinationPath '${tmpExtractDir.replace(/'/g, "''")}' -Force`;
await execFile("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
expandArchiveCommand,
]);
} catch (err) {
logger.error("error executing command - do you have PowerShell available?");
throw err;
}
const tmpFactorioDir = await findFactorioRoot(tmpExtractDir);
const version = await getFactorioVersion(tmpFactorioDir);
const factorioDir = path.join("factorio", version);
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
async function main() {
let yarg = yargs(process.argv.slice(2))
let args = yargs
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
@@ -746,6 +911,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/host]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -780,14 +949,20 @@ async function main() {
;
if (process.platform === "linux") {
yarg = yarg
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
;
args = args.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
});
}
if (supportsHeadlessDownload()) {
args = args.option("download-headless", {
nargs: 0,
describe: "Automatically download and unpack the latest headless Factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
});
}
const args = yarg.parse();
args = args.argv;
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
@@ -810,6 +985,19 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
if (process.platform === "linux") {
await downloadLinuxServer();
} else if (process.platform === "win32") {
await downloadWindowsServer();
} else {
throw new InstallError("--download-headless is only supported on Linux and Windows");
}
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
@@ -817,7 +1005,6 @@ async function main() {
let adminToken = null;
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["config", "create"]);
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
@@ -828,7 +1015,6 @@ async function main() {
if (answers.mode === "standalone") {
logger.info("Setting up host");
await execHost(["config", "create"]);
await execHost(["config", "set", "host.name", "local"]);
let result = await execHost(["config", "show", "host.id"]);
@@ -849,7 +1035,6 @@ async function main() {
if (answers.mode === "host") {
logger.info("Setting up host");
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
await execHost(["config", "create"]);
await execHost(["config", "set", "host.id", hostId]);
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
@@ -866,9 +1051,8 @@ async function main() {
}
if (answers.mode === "ctl") {
await execCtl(["config", "create"]);
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
}
/* eslint-disable no-console */
@@ -879,9 +1063,8 @@ async function main() {
/* eslint-enable no-console */
}
if (import.meta.main) {
if (module === require.main) {
main().catch(err => {
process.exitCode = 1;
if (err instanceof InstallError) {
logger.error(err.message);
} else {
+7 -1
View File
@@ -1,4 +1,6 @@
export function escapeArg(arg) {
"use strict";
function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
@@ -54,3 +56,7 @@ export function escapeArg(arg) {
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+12 -7
View File
@@ -1,7 +1,8 @@
/* eslint-disable no-console */
import chalk from "chalk";
"use strict";
const chalk = require("chalk");
export const levels = Object.freeze({
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
@@ -10,7 +11,7 @@ export const levels = Object.freeze({
verbose: 6,
});
export const colors = {
const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
@@ -20,7 +21,7 @@ export const colors = {
};
let maxLogLevel = levels.info;
export const logger = {};
const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
@@ -41,6 +42,10 @@ for (let [name, level] of Object.entries(levels)) {
};
}
export function setLogLevel(level) {
maxLogLevel = level;
}
module.exports = {
colors,
levels,
setLogLevel: (level) => { maxLogLevel = level; },
logger,
};
+12 -24
View File
@@ -1,39 +1,27 @@
{
"name": "@clusterio/create",
"version": "2.0.2",
"description": "Installer for clusterio",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.23",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "catalog:"
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
}
}
+14 -18
View File
@@ -1,8 +1,9 @@
import { logger } from "./logging.js";
import fs from "node:fs/promises";
import path from "node:path";
"use strict";
const { logger } = require("./logging");
const fs = require("fs-extra");
const path = require("path");
import _package from "./package.json" with { type: "json" };
const _package = require("./package.json");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -60,15 +61,9 @@ async function copyTemplateFile(src, dst, properties) {
return include ? content : "";
});
// Create folder for output file
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
// Attempt to write the output file, warn if it already exists
try {
await fs.writeFile(dst, outputData, { flag: "wx" });
await fs.outputFile(dst, outputData, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
logger.warn(`Could not create file ${dst} because it already exists`);
@@ -78,7 +73,7 @@ async function copyTemplateFile(src, dst, properties) {
}
}
export async function copyPluginTemplates(pluginName, templates) {
async function copyPluginTemplates(pluginName, templates) {
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
const files = new Map();
const prepare = [];
@@ -95,11 +90,8 @@ export async function copyPluginTemplates(pluginName, templates) {
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
const templatePath = path.resolve(
import.meta.dirname,
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
);
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
@@ -121,7 +113,7 @@ export async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
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`));
pluginContexts += 1;
@@ -204,3 +196,7 @@ export async function copyPluginTemplates(pluginName, templates) {
await Promise.all(writes);
logger.info("Successfully wrote all template files");
}
module.exports = {
copyPluginTemplates,
};
+10 -33
View File
@@ -2,7 +2,6 @@
"name": "__plugin_name__",
"version": "0.1.0",
"description": "Example Description. Package. Change me in package.json",
"type": "module",
//%if typescript
"main": "dist/node/index.js",
//%endif
@@ -19,6 +18,9 @@
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
@@ -27,49 +29,24 @@
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
//%if controller | host | ctl
"peerDependenciesMeta": {
//%endif
//%if controller
"@clusterio/controller": {
"optional": true
//%endif
//%if controller & host | controller & ctl
},
//%endif
//%if host
"@clusterio/host": {
"optional": true
//%endif
//%if host & ctl
},
//%endif
//%if ctl
"@clusterio/ctl": {
"optional": true
//%endif
//%if controller | host | ctl
}
},
//%endif
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.9.3",
"@types/node": "^24.13.4",
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
//%endif
//%if typescript & web
"@types/react": "^18.3.31",
"@types/react": "^18.2.21",
//%endif
//%// Everything else
//%if web
"antd": "^5.29.3",
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if webpack
"webpack": "^5.110.3",
"webpack-cli": "^7.2.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
//%endif
//%if controller
@@ -87,7 +64,7 @@
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.30.6"
"@sinclair/typebox": "^0.30.4"
},
"publishConfig": {
"access": "public"
@@ -1,66 +1,85 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Messages requires multi context
import {
const {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages.js";
} = require("./messages");
//%endif
export default async function loadControllerPlugin(context) {
const { controller, logger, plugin } = context;
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
exampleDatabase;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
//%if multi_context // Messages requires multi context
controller.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance, event) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
async handleExampleSubscription(request) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
});
}
//%endif
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
});
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
module.exports = {
ControllerPlugin,
};
+13 -7
View File
@@ -1,6 +1,8 @@
import { CommandTree, Command } from "@clusterio/lib";
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -11,16 +13,20 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args, ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args, control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export default async function loadCtlPlugin(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(pluginCommands);
});
}
}
module.exports = {
CtlPlugin,
};
+29 -18
View File
@@ -1,30 +1,41 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
export default async function loadHostPlugin(context) {
const { host, logger, plugin } = context;
class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
host.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
module.exports = {
HostPlugin,
};
+47 -47
View File
@@ -1,9 +1,42 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
//%if multi_context
import * as Messages from "./messages.js";
const Messages = require("./messages");
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
export const plugin = {
const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
@@ -11,7 +44,7 @@ export const plugin = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller.js",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -27,7 +60,7 @@ export const plugin = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host.js",
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
@@ -43,7 +76,7 @@ export const plugin = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance.js",
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -59,12 +92,12 @@ export const plugin = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl.js",
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -83,43 +116,6 @@ export const plugin = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -128,3 +124,7 @@ export const plugin = {
],
//%endif
};
module.exports = {
plugin,
};
+59 -41
View File
@@ -1,56 +1,74 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
export default async function loadInstancePlugin(context) {
const { instance, logger, plugin } = context;
class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field, curr, prev) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
instance.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if module
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleIPC(event) {
this.logger.info(JSON.stringify(event));
}
//%endif
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
module.exports = {
InstancePlugin,
};
@@ -1,3 +1,11 @@
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context) { }
"use strict";
const { BaseInstancePlugin } = require("@clusterio/host");
class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
module.exports = {
InstancePlugin,
};
@@ -1,7 +1,8 @@
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
import { Type } from "@sinclair/typebox";
"use strict";
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
const { Type } = require("@sinclair/typebox");
export class PluginExampleEvent {
class PluginExampleEvent {
static type = "event";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -23,7 +24,7 @@ export class PluginExampleEvent {
}
}
export class PluginExampleRequest {
class PluginExampleRequest {
static type = "request";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -51,7 +52,7 @@ export class PluginExampleRequest {
}
//%if controller & web // Subscribing requires web content and the controller
export class ExampleSubscribableValue {
class ExampleSubscribableValue {
constructor(id, updatedAtMs, isDeleted) {
this.id = id;
this.updatedAtMs = updatedAtMs;
@@ -69,7 +70,7 @@ export class ExampleSubscribableValue {
}
}
export class ExampleSubscribableUpdate {
class ExampleSubscribableUpdate {
static type = "event";
static src = "controller";
static dst = "control";
@@ -89,3 +90,12 @@ export class ExampleSubscribableUpdate {
}
}
//%endif
module.exports = {
PluginExampleEvent,
PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableValue,
ExampleSubscribableUpdate,
//%endif
};
@@ -10,7 +10,7 @@ import React, {
// } from "antd";
import {
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,26 +21,16 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages.js";
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const [subscribableData, synced] = useSubscribableData();
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -51,39 +41,53 @@ function MyTemplatePage() {
</PageLayout>;
}
export default async function loadWebPlugin(context) {
const { control, logger, plugin } = context;
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.page.view",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
]);
content: <MyTemplatePage/>,
},
];
//%if multi_context
control.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller// Subscribing requires web content and the controller
control.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
}
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -9,61 +9,72 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages.js";
} from "./messages";
//%endif
export default async function loadControllerPlugin(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
exampleDatabase!: Map<string, ExampleSubscribableValue>;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
//%if multi_context // Messages requires multi context
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
async handleExampleSubscription(request: lib.SubscriptionRequest) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
});
}
//%endif
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
}
);
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
+7 -7
View File
@@ -1,7 +1,7 @@
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -12,16 +12,16 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args: { foo: string }, ctl: Ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args: { foo: string }, control: Control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export default async function loadCtlPlugin(context: CtlPluginContext) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
export class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand: CommandTree) {
rootCommand.add(pluginCommands);
});
}
}
+24 -18
View File
@@ -1,30 +1,36 @@
import type { HostPluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
export default async function loadHostPlugin(context: HostPluginContext) {
const { host, logger, plugin } = context;
export class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
+44 -65
View File
@@ -1,8 +1,40 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages.js";
import * as Messages from "./messages";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config | multi_context | web
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
declare module "@clusterio/lib" {
//%endif
@@ -22,27 +54,11 @@ declare module "@clusterio/lib" {
}
//%endif
//%if ctl & config
export interface CtlConfigFields {
"__plugin_name__.myCtlField": string;
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
//%if multi_context | web
export interface Permissions {
//%endif
//%if multi_context
"__plugin_name__.example.permission.event": never;
"__plugin_name__.example.permission.request": never;
//%endif
//%if controller & web // Subscribing requires web content and the controller
"__plugin_name__.example.permission.subscribe": never;
//%endif
//%if web
"__plugin_name__.page.view": never;
//%endif
//%if multi_context | web
}
//%endif
//%if config | multi_context | web
//%if config
}
//%endif
@@ -55,7 +71,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller.js",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -71,7 +87,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host.js",
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
@@ -87,7 +103,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance.js",
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -103,12 +119,12 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl.js",
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -127,43 +143,6 @@ export const plugin: lib.PluginDeclaration = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
+54 -42
View File
@@ -1,64 +1,76 @@
import * as lib from "@clusterio/lib";
import type { InstancePluginContext } from "@clusterio/host";
import { BaseInstancePlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
//%if module
type PluginExampleIPC = {
type PuginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
export default async function loadInstancePlugin(context: InstancePluginContext) {
const { instance, logger, plugin } = context;
export class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%endif
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if module
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleIPC(event: PuginExampleIPC) {
this.logger.info(JSON.stringify(event));
}
//%endif
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
@@ -1,5 +1,7 @@
import type { InstancePluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context: InstancePluginContext) { }
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
@@ -9,5 +9,5 @@
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -10,8 +10,7 @@ import React, {
// } from "antd";
import {
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
type WebPluginContext,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -22,26 +21,16 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages.js";
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber: lib.MapSubscriber<ExampleSubscribableUpdate>;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const [subscribableData, synced] = useSubscribableData();
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -52,39 +41,53 @@ function MyTemplatePage() {
</PageLayout>;
}
export default async function loadWebPlugin(context: WebPluginContext) {
const { control, logger, plugin } = context;
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.page.view",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
]);
content: <MyTemplatePage/>,
},
];
//%if multi_context
control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller // Subscribing requires web content and the controller
control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
}
-1
View File
@@ -1 +0,0 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+10 -31
View File
@@ -17,12 +17,9 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for ctl.
Defaults to `config-ctl.json` and is required for operation.
Defaults to `config-control.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -30,28 +27,17 @@ Configure plugins available to be loaded by ctl.
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusterioctl plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, adding a plugin checked out in the external_plugins directory:
For example, installing the Subspace Storage plugin:
npx clusterioctl plugin add ./external_plugins/my_plugin
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -73,20 +59,13 @@ Since the `plugin-list.json` is shared between controller, host and ctl you usua
Lists the plugins set up to be available by name followed by path.
### `config`
### `config-control`
Manage the ctl configuration.
This allows setting the url and token to the controller to manage.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
#### `config-control set <config-entry> [value]`
Set a config entry to the given value.
If value is not provided the entry is set to null.
@@ -95,12 +74,12 @@ If the config entry is of type object the value must be a valid JSON serializati
See docs/configuration.md in the main repositiory for the available configuration.
#### `config show <config-entry>`
#### `config-control show <config-entry>`
Shows the value for a single config entry.
#### `config list`
#### `config-control list`
Lists up all configuration entries with their currently configured values.
+104 -72
View File
@@ -4,26 +4,27 @@
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import yargs, { type Argv } from "yargs";
import path from "path";
import packageConfig from "@clusterio/ctl/package.json" with { type: "json" };
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
import "./src/set_lazy_schema_compliation.js";
(global as any).lazySchemaCompilation = true;
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import * as commands from "./src/commands.js";
import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
import * as commands from "./src/commands";
import BaseCtlPlugin from "./src/BaseCtlPlugin";
/**
* Connector for control connection to controller
* @private
*/
class CtlConnector extends lib.WebSocketClientConnector {
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, token: string) {
@@ -37,7 +38,7 @@ class CtlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
packageConfig.version,
version,
)
)
);
@@ -45,23 +46,27 @@ class CtlConnector extends lib.WebSocketClientConnector {
}
/**
* Handles running ctl
* Handles running the control
*
* Connects to the controller over WebSocket and sends commands to it.
* @static
*/
export class Ctl extends lib.Link {
/** Ctl config used for connecting to the controller. */
config: lib.CtlConfig;
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: CtlConnector,
ctlConfig: lib.CtlConfig,
connector: ControlConnector,
controlConfig: lib.ControlConfig,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = ctlConfig;
this.config = controlConfig;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
@@ -92,7 +97,7 @@ export class Ctl extends lib.Link {
async shutdown() {
try {
await (this.connector as CtlConnector).disconnect();
await (this.connector as ControlConnector).disconnect();
} catch (err) {
if (!(err instanceof lib.SessionLost)) {
throw err;
@@ -101,23 +106,27 @@ export class Ctl extends lib.Link {
}
}
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
async function loadPlugins(pluginList: Map<string, string>) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
let ctlPlugins = new Map<string, BaseCtlPlugin>();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.ctlEntrypoint) {
continue;
}
const context = { hooks, logger, plugin: pluginInfo };
await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
ctlPlugins.add(pluginInfo);
let CtlPlugin = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
"CtlPlugin",
BaseCtlPlugin,
);
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
ctlPlugins.set(pluginInfo.name, ctlPlugin);
await ctlPlugin.init();
}
return ctlPlugins;
}
@@ -128,21 +137,19 @@ interface CtlArguments {
logLevel: keyof typeof levels,
config: string,
pluginList: string,
bypassLockFile: boolean,
createConfig: boolean,
}
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
ctlHooks: CtlHooks;
ctlPlugins?: Map<string, BaseCtlPlugin>;
rootCommands?: lib.CommandTree;
ctlConfig?: lib.CtlConfig;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
ctlHooks: CtlHooks = new CtlHooks(logger),
ctlPlugins?: Map<string, BaseCtlPlugin>,
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
@@ -168,16 +175,10 @@ export async function initialize(
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-ctl.json",
default: "config-control.json",
defaultDescription: "auto",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -186,7 +187,7 @@ export async function initialize(
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage ctl config", lib.configCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
@@ -207,47 +208,63 @@ export async function initialize(
lib.handleUnhandledErrors();
}
// Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
// Discover and load plugins. This check exists to allow tests to inject plugins.
if (!ctlPlugins || args._[0] === "plugin") {
logger.verbose(`Loading available plugins from ${args.pluginList}`);
const pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return { args, ctlHooks, shouldRun: false };
return { args, shouldRun: false };
}
logger.verbose("Loading Plugins");
await loadPlugins(pluginList, ctlHooks);
ctlPlugins = await loadPlugins(pluginList);
}
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlHooks, parser);
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
args = parser
.help()
.strict()
.parse(argv) as CtlArguments
;
const [ctlConfig, ctlConfigLock] = await lib.loadConfigFromArgs(
args,
lib.CtlConfig,
"control",
);
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
if (args._.length === 0) {
parser.showHelp();
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the config command before trying to connect.
if (args._[0] === "config") {
await lib.handleConfigCommand(args, ctlConfig, ctlConfigLock);
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: false };
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: true };
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
@@ -260,30 +277,31 @@ export function selectTargetCommand(args: CtlArguments, rootCommands: lib.Comman
return targetCommand;
}
async function startCtl() {
async function startControl() {
const {
args,
shouldRun,
ctlPlugins,
rootCommands,
ctlConfig,
controlConfig,
} = await initialize(process.argv.slice(2));
if (!shouldRun || !rootCommands || !ctlConfig) {
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
return;
}
if (!ctlConfig.get("ctl.controller_url") || !ctlConfig.get("ctl.controller_token")) {
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let controlConnector = new CtlConnector(
ctlConfig.get("ctl.controller_url")!,
ctlConfig.get("ctl.max_reconnect_delay"),
ctlConfig.get("ctl.controller_token")!,
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
controlConfig.get("control.controller_token")!,
);
let ctl = new Ctl(controlConnector, ctlConfig);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
@@ -293,16 +311,22 @@ async function startCtl() {
throw err;
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", ctl.shutdown.bind(ctl)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", ctl.shutdown.bind(ctl)));
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
try {
const targetCommand = selectTargetCommand(args, rootCommands);
await targetCommand.run(args, ctl);
await targetCommand.run(args, control);
} catch (err) {
ctl.keepOpen = false;
control.keepOpen = false;
if (err instanceof lib.CommandError) {
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
@@ -320,23 +344,31 @@ async function startCtl() {
}
} finally {
if (!ctl.keepOpen) {
await ctl.shutdown();
if (!control.keepOpen) {
await control.shutdown();
}
}
}
export function bootstrap() {
startCtl().catch(err => {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
if (err.errors) {
logger.fatal(JSON.stringify(err.errors, null, "\t"));
}
if (!(err instanceof lib.StartupError)) {
logger.fatal(`
+---------------------------------------------------------------+
| Unexpected error occured while starting clussterioctl, please |
| report it to https://github.com/clusterio/clusterio/issues |
+---------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
@@ -352,6 +384,6 @@ ${err.stack}`
});
}
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { bootstrap } from "./ctl.js";
export * from "./ctl.js";
export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
import { bootstrap } from "./ctl";
export * from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+18 -27
View File
@@ -1,28 +1,9 @@
{
"name": "@clusterio/ctl",
"version": "2.0.2",
"description": "Clusterio command line management tool",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -31,19 +12,29 @@
"bin": {
"clusterioctl": "./dist/node/ctl.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"yargs": "catalog:"
"as-table": "^1.0.55",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/yargs": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
}
+31 -53
View File
@@ -1,18 +1,38 @@
import * as lib from "@clusterio/lib";
export type CtlPluginContext = lib.PluginLoadContext<{
hooks: CtlHooks
}>;
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
/**
* Collection of clusterioctl plugin hooks
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
export class CtlHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.addCommands = this.newHook();
export default class BaseCtlPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called to add commands to the command line interface.
*
@@ -23,47 +43,5 @@ export class CtlHooks extends lib.AsyncHookCollection {
* @param rootCommand -
* Root of the clusterioctl command tree.
*/
readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseCtlPlugin {
addCommands?(rootCommand: lib.CommandTree): Promise<void>;
}
/**
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseCtlPlugin {
constructor(
/**
* The plugin's own info module
*/
public info: lib.PluginNodeEnvInfo,
public logger: lib.Logger,
private hooks: CtlHooks,
) {
if (this.addCommands) {
hooks.addCommands.attach(info.name, this.addCommands.bind(this));
}
}
static fromContext(context: CtlPluginContext) {
return new this(context.plugin, context.logger, context.hooks);
}
detachHooks() {
this.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async addCommands(rootCommand: CommandTree) { }
}
+15 -13
View File
@@ -1,19 +1,19 @@
import type { Argv } from "yargs";
import * as lib from "@clusterio/lib";
import { controllerCommands } from "./commands_controller.js";
import { hostCommands } from "./commands_host.js";
import { instanceCommands } from "./commands_instance.js";
import { modPackCommands } from "./commands_mod_pack.js";
import { modCommands } from "./commands_mod.js";
import { permissionCommands } from "./commands_permission.js";
import { roleCommands } from "./commands_role.js";
import { userCommands } from "./commands_user.js";
import { logCommands } from "./commands_log.js";
import { debugCommands } from "./commands_debug.js";
import type { CtlHooks } from "./BaseCtlPlugin.js";
import type BaseCtlPlugin from "./BaseCtlPlugin";
import { controllerCommands } from "./commands_controller";
import { hostCommands } from "./commands_host";
import { instanceCommands } from "./commands_instance";
import { modPackCommands } from "./commands_mod_pack";
import { modCommands } from "./commands_mod";
import { permissionCommands } from "./commands_permission";
import { roleCommands } from "./commands_role";
import { userCommands } from "./commands_user";
import { logCommands } from "./commands_log";
import { debugCommands } from "./commands_debug";
export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
export async function registerCommands(ctlPlugins: Map<string, BaseCtlPlugin>, yargs: Argv) {
const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(controllerCommands);
rootCommands.add(hostCommands);
@@ -26,7 +26,9 @@ export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
await ctlHooks.addCommands.invoke(rootCommands);
for (let controlPlugin of ctlPlugins.values()) {
await controlPlugin.addCommands(rootCommands);
}
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
+31 -40
View File
@@ -1,49 +1,38 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
controllerCommands.add(new lib.Command({
definition: ["stop", "Stop controller"],
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerStopRequest());
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerStopRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["mark-restart-required", "Mark the controller as requiring a restart"],
handler: async function(_args: object, ctl: Ctl) {
await ctl.send(new lib.ControllerMarkRestartRequiredRequest());
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
handler: async function(args: { restart: boolean, all: boolean }, ctl: Ctl) {
if (args.all) {
await ctl.send(new lib.UpdateAllRequest());
} else {
await ctl.send(new lib.ControllerUpdateRequest());
}
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
if (args.restart) {
await ctl.send(new lib.ControllerRestartRequest());
await control.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
@@ -56,8 +45,8 @@ const controllerConfigCommands = new lib.CommandTree({
controllerConfigCommands.add(new lib.Command({
definition: ["list", "List controller configuration"],
handler: async function(args: object, ctl: Ctl) {
let config = await ctl.send(new lib.ControllerConfigGetRequest());
handler: async function(args: object, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -73,13 +62,13 @@ controllerConfigCommands.add(new lib.Command({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, ctl: Ctl) {
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await ctl.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
},
}));
@@ -92,7 +81,7 @@ controllerConfigCommands.add(new lib.Command({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, ctl: Ctl) {
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -108,7 +97,7 @@ controllerConfigCommands.add(new lib.Command({
}
value = args.value;
}
await ctl.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
@@ -119,15 +108,17 @@ controllerConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { editor: string }, ctl: Ctl) {
let config = await ctl.send(new lib.ControllerConfigGetRequest());
handler: async function(args: { editor: string }, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(config, lib.ControllerConfig);
let allConfigElements = serializedConfigToString(
config, lib.ControllerConfig, {}
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -142,7 +133,7 @@ controllerConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await ctl.send(new lib.ControllerConfigSetFieldRequest(
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
@@ -171,8 +162,8 @@ const controllerPluginCommands = new lib.CommandTree({
});
controllerPluginCommands.add(new lib.Command({
definition: ["list", "List plugins on controller"],
handler: async function(args: object, ctl: Ctl) {
let url = new URL(ctl.config.get("ctl.controller_url")!);
handler: async function(args: object, control: Control) {
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
@@ -183,10 +174,10 @@ controllerPluginCommands.add(new lib.Command({
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
await ctl.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await ctl.send(new lib.ControllerRestartRequest());
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
@@ -197,10 +188,10 @@ controllerPluginCommands.add(new lib.Command({
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
await ctl.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await ctl.send(new lib.ControllerRestartRequest());
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
+4 -24
View File
@@ -1,31 +1,11 @@
import * as lib from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import type { Control } from "../ctl";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
handler: async function(args: object, ctl: Ctl) {
await ctl.send(new lib.DebugDumpWsRequest());
ctl.keepOpen = true;
},
}));
debugCommands.add(new lib.Command({
definition: [
"parse-exchange-string <exchange-string>",
"Parse and print the contents of an exchange string",
(yargs) => {
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
}],
handler: async function(args: { exchangeString: string, pretty: boolean }, ctl: Ctl) {
const result = lib.readMapExchangeString(args.exchangeString);
if (args.pretty) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result, null, 2));
} else {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result));
}
handler: async function(args: object, control: Control) {
await control.send(new lib.DebugDumpWsRequest());
control.keepOpen = true;
},
}));
+61 -66
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
@@ -6,9 +6,9 @@ import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -17,9 +17,9 @@ hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostStopRequest());
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
@@ -27,26 +27,16 @@ hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["mark-restart-required <host>", "Mark the given host as requiring a restart", (yargs) => {
yargs.positional("host", { describe: "Host to mark", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest());
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, ctl: Ctl) {
let hosts = await ctl.send(new lib.HostListRequest());
handler: async function(args: object, control: Control) {
let hosts = await control.send(new lib.HostListRequest());
print(asTable(hosts));
},
}));
@@ -55,9 +45,9 @@ hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, ctl: Ctl) {
handler: async function(args: { id?: number }, control: Control) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await ctl.send(new lib.HostGenerateTokenRequest(hostId));
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
@@ -66,9 +56,9 @@ hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.send(new lib.HostRevokeTokensRequest(hostId));
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
@@ -85,9 +75,9 @@ hostCommands.add(new lib.Command({
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
ctl: Ctl
control: Control
) {
let config = await ctl.send(
let config = await control.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
@@ -97,7 +87,7 @@ hostCommands.add(new lib.Command({
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
@@ -111,17 +101,12 @@ hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
handler: async function(args: { host: string, restart: boolean, all: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
if (args.all) {
await ctl.sendTo({ hostId }, new lib.UpdateAllRequest());
} else {
await ctl.sendTo({ hostId }, new lib.HostUpdateRequest());
}
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 ctl.sendTo({ hostId }, new lib.HostRestartRequest());
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
@@ -136,9 +121,9 @@ hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -157,15 +142,15 @@ hostConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
ctl: Ctl,
control: Control,
) {
let hostId = await lib.resolveHost(ctl, args.host);
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
@@ -181,9 +166,9 @@ hostConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
ctl: Ctl
control: Control
) {
let hostId = await lib.resolveHost(ctl, args.host);
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -199,7 +184,7 @@ hostConfigCommands.add(new lib.Command({
}
value = args.value;
}
await ctl.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
@@ -212,16 +197,21 @@ hostConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl host config edit <editor of choice>"`);
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(config, lib.HostConfig);
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -236,14 +226,19 @@ hostConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
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(`\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);
}
}
@@ -267,9 +262,9 @@ hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
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,
}))));
@@ -281,11 +276,11 @@ hostPluginCommands.add(new lib.Command({
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
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 ctl.sendTo({ hostId }, new lib.HostRestartRequest());
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
@@ -297,11 +292,11 @@ hostPluginCommands.add(new lib.Command({
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
let hostId = await lib.resolveHost(ctl, args.host);
await ctl.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
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 ctl.sendTo({ hostId }, new lib.HostRestartRequest());
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
+135 -111
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
@@ -9,9 +9,9 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
@@ -21,8 +21,8 @@ export const instanceCommands = new lib.CommandTree({
});
instanceCommands.add(new lib.Command({
definition: [["list", "l"], "List instances known to the controller"],
handler: async function(args: object, ctl: Ctl) {
let list = await ctl.send(new lib.InstanceDetailsListRequest());
handler: async function(args: object, control: Control) {
let list = await control.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
},
}));
@@ -36,13 +36,13 @@ instanceCommands.add(new lib.Command({
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, ctl: Ctl) {
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
await ctl.send(new lib.InstanceCreateRequest(
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
@@ -58,9 +58,9 @@ instanceConfigCommands.add(new lib.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
@@ -79,15 +79,15 @@ instanceConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
ctl: Ctl,
control: Control,
) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await ctl.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
},
}));
@@ -103,9 +103,9 @@ instanceConfigCommands.add(new lib.Command({
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
ctl: Ctl
control: Control
) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
@@ -136,7 +136,7 @@ instanceConfigCommands.add(new lib.Command({
}
value = args.value;
}
await ctl.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
@@ -149,16 +149,21 @@ instanceConfigCommands.add(new lib.Command({
default: "",
});
}],
handler: async function(args: { instance: string, editor: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
handler: async function(args: { instance: string, editor: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl instance config edit <editor of choice>"`);
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(config, lib.InstanceConfig);
let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
@@ -173,15 +178,20 @@ instanceConfigCommands.add(new lib.Command({
for (let index in final) {
if (index in final) {
try {
await ctl.send(new lib.InstanceConfigSetFieldRequest(
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
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(`\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);
}
}
@@ -201,10 +211,10 @@ instanceCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
}],
handler: async function(args: { instance: string, host?: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let hostId = args.host ? await lib.resolveHost(ctl, args.host) : undefined;
await ctl.send(new lib.InstanceAssignRequest(instanceId, hostId));
handler: async function(args: { instance: string, host?: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined;
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
},
}));
@@ -240,9 +250,9 @@ instanceSaveCommands.add(new lib.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let saves = await ctl.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
@@ -270,12 +280,12 @@ instanceSaveCommands.add(new lib.Command({
mapGenSettings?: string,
mapSettings?: string,
},
ctl: Ctl
control: Control
) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let instanceId = await lib.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo(
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
@@ -288,9 +298,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args: { instance: string, oldName: string, newName: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
handler: async function(args: { instance: string, oldName: string, newName: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
},
}));
@@ -300,9 +310,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args: { instance: string, source: string, destination: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
handler: async function(args: { instance: string, source: string, destination: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
},
}));
@@ -314,24 +324,24 @@ instanceSaveCommands.add(new lib.Command({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, ctl: Ctl) {
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Save name must end with .zip");
}
let instanceId = await lib.resolveInstance(ctl, args.instance);
let url = new URL(ctl.config.get("ctl.controller_url")!);
let instanceId = await lib.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
const response = await fetch(url, {
method: "POST",
body: (await fs.open(args.filepath)).createReadStream(),
body: await fs.createReadStream(args.filepath),
duplex: "half",
headers: {
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
@@ -379,11 +389,11 @@ instanceSaveCommands.add(new lib.Command({
targetSave: string,
copy: boolean,
},
ctl: Ctl
control: Control
) {
let sourceInstanceId = await lib.resolveInstance(ctl, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(ctl, args.targetInstance);
let storedName = await ctl.send(
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
@@ -401,13 +411,37 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
let streamId = await ctl.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
let url = new URL(ctl.config.get("ctl.controller_url")!);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
const filename = await lib.downloadFile(url, args.save, "rename");
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server.");
}
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
let filename = await lib.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
@@ -418,9 +452,9 @@ instanceSaveCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
},
}));
instanceCommands.add(instanceSaveCommands);
@@ -429,20 +463,10 @@ instanceCommands.add(new lib.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["save-game <instance>", "Save the running game of an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to save the game on", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.sendTo({ instanceId }, new lib.InstanceSaveGameRequest());
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
@@ -450,10 +474,10 @@ instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
},
}));
@@ -465,11 +489,11 @@ instanceCommands.add(new lib.Command({
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
const instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
ctl.keepOpen = args.keepOpen;
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
@@ -480,11 +504,11 @@ instanceCommands.add(new lib.Command({
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, ctl: Ctl) {
const instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
ctl.keepOpen = args.keepOpen;
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;
},
}));
@@ -499,9 +523,9 @@ instanceCommands.add(new lib.Command({
},
});
}],
handler: async function(args: { force: boolean }, ctl: Ctl) {
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
@@ -531,7 +555,7 @@ instanceCommands.add(new lib.Command({
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
@@ -565,15 +589,15 @@ instanceCommands.add(new lib.Command({
mapSettings?: string,
keepOpen: boolean,
},
ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await ctl.sendTo(
await control.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
ctl.keepOpen = args.keepOpen;
control.keepOpen = args.keepOpen;
},
}));
@@ -581,18 +605,18 @@ instanceCommands.add(new lib.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceStopRequest());
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStopRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, ctl: Ctl) {
handler: async function(_args, control: Control) {
// Get all instances
const instances = await ctl.send(new lib.InstanceDetailsListRequest());
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
@@ -611,7 +635,7 @@ instanceCommands.add(new lib.Command({
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await ctl.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
@@ -627,10 +651,10 @@ instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
await ctl.sendTo({ instanceId }, new lib.InstanceKillRequest());
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceKillRequest());
},
}));
@@ -638,9 +662,9 @@ instanceCommands.add(new lib.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args: { instance: string }, ctl: Ctl) {
let instanceId = await lib.resolveInstance(ctl, args.instance);
await ctl.send(new lib.InstanceDeleteRequest(instanceId));
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteRequest(instanceId));
},
}));
@@ -649,9 +673,9 @@ instanceCommands.add(new lib.Command({
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args: { instance: string, command: string }, ctl: Ctl) {
let result = await ctl.sendTo(
{ instanceId: await lib.resolveInstance(ctl, args.instance) },
handler: async function(args: { instance: string, command: string }, control: Control) {
let result = await control.sendTo(
{ instanceId: await lib.resolveInstance(control, args.instance) },
new lib.InstanceSendRconRequest(args.command),
);
+10 -10
View File
@@ -2,7 +2,7 @@ import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import type { Control } from "../ctl";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
@@ -16,17 +16,17 @@ logCommands.add(new lib.Command({
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
ctl: Ctl
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
await ctl.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
ctl.keepOpen = true;
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
control.keepOpen = true;
},
}));
@@ -52,16 +52,16 @@ logCommands.add(new lib.Command({
limit: number,
start: boolean,
},
ctl: Ctl
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
let result = await ctl.send(new lib.LogQueryRequest(
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
let result = await control.send(new lib.LogQueryRequest(
args.all,
args.controller,
hostIds,
+45 -27
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
@@ -7,8 +7,8 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
@@ -19,11 +19,11 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
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 ctl.send(new lib.ModGetRequest(args.name, args.modVersion));
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
@@ -48,8 +48,8 @@ modCommands.add(new lib.Command({
},
});
}],
handler: async function(args: { fields: string[] }, ctl: Ctl) {
let mods = await ctl.send(new lib.ModListRequest());
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
@@ -106,15 +106,11 @@ modCommands.add(new lib.Command({
sortOrder: "asc" | "desc",
fields: string[],
},
ctl: Ctl
control: Control
) {
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
if (searchVersion === undefined) {
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
}
let response = await ctl.send(new lib.ModSearchRequest(
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseMajorMinorVersion(searchVersion),
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
@@ -143,22 +139,22 @@ modCommands.add(new lib.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args: { file: string }, ctl: Ctl) {
handler: async function(args: { file: string }, control: Control) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Mod filename must end with .zip");
}
let url = new URL(ctl.config.get("ctl.controller_url")!);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let response = await fetch(url, {
method: "POST",
body: (await fs.open(args.file)).createReadStream(),
body: fs.createReadStream(args.file),
duplex: "half",
headers: {
"X-Access-Token": ctl.config.get("ctl.controller_token")!,
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
@@ -190,16 +186,38 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
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 ctl.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(ctl.config.get("ctl.controller_url")!);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server");
}
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
await lib.downloadFile(url, filename, "overwrite");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${filename}`);
},
@@ -210,10 +228,10 @@ modCommands.add(new lib.Command({
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, ctl: Ctl) {
if (!lib.isSourceVersion(args.modVersion)) {
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 ctl.send(new lib.ModDeleteRequest(args.name, args.modVersion));
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
+23 -23
View File
@@ -2,8 +2,8 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -12,12 +12,12 @@ modPackCommands.add(new lib.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args: { modPack: string }, ctl: Ctl) {
handler: async function(args: { modPack: string }, control: Control) {
let mods = new Map(
(await ctl.send(new lib.ModListRequest())).map(m => [m.id, m])
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
@@ -57,8 +57,8 @@ modPackCommands.add(new lib.Command({
modPackCommands.add(new lib.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args: object, ctl: Ctl) {
let modPacks = await ctl.send(new lib.ModPackListRequest());
handler: async function(args: object, control: Control) {
let modPacks = await control.send(new lib.ModPackListRequest());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isSourceVersion(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)) {
@@ -179,7 +179,7 @@ modPackCommands.add(new lib.Command({
stringSetting?: string[],
colorSetting?: string[],
},
ctl: Ctl,
control: Control,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
@@ -193,7 +193,7 @@ modPackCommands.add(new lib.Command({
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await ctl.send(new lib.ModPackCreateRequest(modPack));
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
@@ -202,9 +202,9 @@ modPackCommands.add(new lib.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args: { string: string }, ctl: Ctl) {
handler: async function(args: { string: string }, control: Control) {
const modPack = lib.ModPack.fromModPackString(args.string);
await ctl.send(new lib.ModPackCreateRequest(modPack));
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
@@ -213,9 +213,9 @@ modPackCommands.add(new lib.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args: { modPack: string }, ctl: Ctl) {
const modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
handler: async function(args: { modPack: string }, control: Control) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
print(modPack.toModPackString());
},
@@ -257,10 +257,10 @@ modPackCommands.add(new lib.Command({
colorSetting?: string[],
removeSetting?: string[],
},
ctl: Ctl,
control: Control,
) {
const modPack = await ctl.send(
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, args.modPack))
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
if (args.name) { modPack.name = args.name; }
@@ -300,7 +300,7 @@ modPackCommands.add(new lib.Command({
}
}
}
await ctl.send(new lib.ModPackUpdateRequest(modPack));
await control.send(new lib.ModPackUpdateRequest(modPack));
},
}));
@@ -308,8 +308,8 @@ modPackCommands.add(new lib.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args: { modPack: string }, ctl: Ctl) {
const id = await lib.resolveModPack(ctl, args.modPack);
await ctl.send(new lib.ModPackDeleteRequest(id));
handler: async function(args: { modPack: string }, control: Control) {
const id = await lib.resolveModPack(control, args.modPack);
await control.send(new lib.ModPackDeleteRequest(id));
},
}));
+4 -4
View File
@@ -1,16 +1,16 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new lib.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args: object, ctl: Ctl) {
let permissions = await ctl.send(new lib.PermissionListRequest());
handler: async function(args: object, control: Control) {
let permissions = await control.send(new lib.PermissionListRequest());
print(asTable(permissions));
},
}));
+13 -13
View File
@@ -2,16 +2,16 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
roleCommands.add(new lib.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args: object, ctl: Ctl) {
let roles = await ctl.send(new lib.RoleListRequest());
handler: async function(args: object, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
print(asTable(roles));
},
}));
@@ -24,8 +24,8 @@ roleCommands.add(new lib.Command({
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args: { name: string, description: string, permissions: string[] }, ctl: Ctl) {
let id = await ctl.send(new lib.RoleCreateRequest(
handler: async function(args: { name: string, description: string, permissions: string[] }, control: Control) {
let id = await control.send(new lib.RoleCreateRequest(
args.name,
args.description,
args.permissions,
@@ -58,9 +58,9 @@ roleCommands.add(new lib.Command({
removePerms?: string[],
grantDefault?: boolean,
},
ctl: Ctl
control: Control
) {
let role = await lib.retrieveRole(ctl, args.role);
let role = await lib.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
@@ -81,10 +81,10 @@ roleCommands.add(new lib.Command({
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await ctl.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await ctl.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
@@ -93,8 +93,8 @@ roleCommands.add(new lib.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args: { role: string }, ctl: Ctl) {
let role = await lib.retrieveRole(ctl, args.role);
await ctl.send(new lib.RoleDeleteRequest(role.id));
handler: async function(args: { role: string }, control: Control) {
let role = await lib.retrieveRole(control, args.role);
await control.send(new lib.RoleDeleteRequest(role.id));
},
}));
+33 -33
View File
@@ -1,10 +1,10 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
import type { Ctl } from "../ctl.js";
import { print } from "./command_ops.js";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -16,8 +16,8 @@ userCommands.add(new lib.Command({
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { name: string, instanceStats: boolean }, ctl: Ctl) {
let user = await ctl.send(new lib.UserGetRequest(args.name));
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
@@ -27,7 +27,7 @@ userCommands.add(new lib.Command({
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await ctl.send(new lib.InstanceDetailsListRequest());
let instances = await control.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
@@ -51,8 +51,8 @@ userCommands.add(new lib.Command({
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { stats: boolean, attributes: boolean }, ctl: Ctl) {
let users = await ctl.send(new lib.UserListRequest());
handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) {
let users = await control.send(new lib.UserListRequest());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
@@ -82,8 +82,8 @@ userCommands.add(new lib.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args: { name: string }, ctl: Ctl) {
await ctl.send(new lib.UserCreateRequest(args.name));
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserCreateRequest(args.name));
},
}));
@@ -91,8 +91,8 @@ userCommands.add(new lib.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args: { name: string }, ctl: Ctl) {
await ctl.send(new lib.UserRevokeTokenRequest(args.name));
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserRevokeTokenRequest(args.name));
},
}));
@@ -104,8 +104,8 @@ userCommands.add(new lib.Command({
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, revoke: boolean, create: boolean }, ctl: Ctl) {
await ctl.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
handler: async function(args: { user: string, revoke: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
},
}));
@@ -117,8 +117,8 @@ userCommands.add(new lib.Command({
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, remove: boolean, create: boolean }, ctl: Ctl) {
await ctl.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
handler: async function(args: { user: string, remove: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
},
}));
@@ -138,9 +138,9 @@ userCommands.add(new lib.Command({
reason: string,
create: boolean
},
ctl: Ctl
control: Control
) {
await ctl.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
@@ -149,8 +149,8 @@ userCommands.add(new lib.Command({
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args: { user: string, roles: string[] }, ctl: Ctl) {
let roles = await ctl.send(new lib.RoleListRequest());
handler: async function(args: { user: string, roles: string[] }, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
let resolvedRoles = [];
for (let roleName of args.roles) {
@@ -174,7 +174,7 @@ userCommands.add(new lib.Command({
}
}
await ctl.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
@@ -182,8 +182,8 @@ userCommands.add(new lib.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args: { user: string }, ctl: Ctl) {
await ctl.send(new lib.UserDeleteRequest(args.user));
handler: async function(args: { user: string }, control: Control) {
await control.send(new lib.UserDeleteRequest(args.user));
},
}));
@@ -194,7 +194,7 @@ async function handleImportOrRestore(args: {
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, ctl: Ctl, restore: 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]
@@ -218,13 +218,13 @@ async function handleImportOrRestore(args: {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await ctl.send(
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
@@ -239,8 +239,8 @@ userCommands.add(new lib.Command({
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, ctl: any) {
await handleImportOrRestore(args, ctl, false);
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
@@ -255,8 +255,8 @@ userCommands.add(new lib.Command({
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, ctl: any) {
await handleImportOrRestore(args, ctl, true);
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
},
}));
@@ -276,7 +276,7 @@ userCommands.add(new lib.Command({
bans: boolean,
admins: boolean,
whitelist: boolean,
}, ctl: Ctl) {
}, 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);
@@ -296,9 +296,9 @@ userCommands.add(new lib.Command({
exportType = "whitelist";
}
await fs.writeFile(args.filepath, JSON.stringify(await ctl.send(
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), undefined, "\t"));
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
+24 -21
View File
@@ -15,14 +15,18 @@ export async function configToKeyVal(data: string) {
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
// split on the first = only, values may contain = themselves
let split = filtered[index].indexOf("=");
if (split === -1) {
// no value given, it's a empty field and therefor null
final[filtered[index].trim()] = "";
continue;
let split = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = split[1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[filtered[index].slice(0, split).trim()] = filtered[index].slice(split + 1).trim();
final[finalIndex] = part;
}
}
return final;
@@ -30,26 +34,25 @@ export async function configToKeyVal(data: string) {
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
const config = configClass.fromJSON({}, "control");
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
const def = (configClass.fieldDefinitions as lib.ConfigDefs<any>)[name];
try {
if (!config.canAccess(name, lib.ConfigAccess.readWrite) || def.hidden) {
continue;
}
} catch (err) {
// Field does not exist in the config class
if (name in disallowedList) {
continue;
}
if (value === null) {
value = "";
} else if (typeof value === "object") {
value = JSON.stringify(value);
let desc = "";
try {
desc += (configGroup.fieldDefinitions as any)[name]!.description;
} catch (err) {
desc += "No description found";
}
allConfigElements += `# ${def.description ?? "No description found"}\n${name} = ${value}\n\n`;
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${name} = ${value}\n\n`;
}
return allConfigElements;
}
@@ -1,3 +0,0 @@
// This needs to be a separate file due to ECMAScript Modules evaluating code in
// imported modules before any code in the file that imports it.
(global as any).lazySchemaCompilation = true;
-1
View File
@@ -1 +0,0 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+6 -52
View File
@@ -16,12 +16,9 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the host.
Defaults to `config-host.json` and is required for operation.
Defaults to `config-host.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -29,28 +26,17 @@ Configure plugins available to be loaded by the host.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusteriohost plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, adding a plugin checked out in the external_plugins directory:
For example, installing the Subspace Storage plugin:
npx clusteriohost plugin add ./external_plugins/my_plugin
npm install @clusterio/plugin-subspace_storage
npx clusteriohost plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -78,13 +64,6 @@ Manage the host configuration offline.
This should only be used when the host is stopped, otherwise the config read might be out of date and config changes will be overwritten when the host shuts down.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
@@ -99,31 +78,6 @@ See docs/configuration.md in the main repositiory for the available configuratio
Shows the value for a single config entry.
### `create-scenario <output>`
Create a scenario with the Clusterio modules patched into it and write it to `output`.
This is the same patching that is applied to saves when an instance starts, see docs/how-it-works.md in the main repository.
The result can be used as a regular scenario outside of Clusterio by copying it to the `scenarios` folder of Factorio or into a mod.
The IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected.
By default the freeplay scenario from the latest Factorio install in `host.factorio_directory` is patched with the modules from all plugins that are enabled in the host config.
* `--scenario <path>` Scenario directory, zipped scenario or save to patch instead of freeplay.
This has the same restrictions as save patching, the scenario must either use the event_handler library and have been seen by Clusterio before, or already have been patched by Clusterio.
* `--factorio-version <version>` Factorio version to take the freeplay scenario from.
Defaults to `latest`.
* `--plugins <name>...` Only patch in modules from the given plugins.
* `--zip` Write the scenario as a zip file instead of a directory.
Factorio does not load zipped scenarios, extract it before use.
For example, creating a scenario with only the player_sync module:
npx clusteriohost create-scenario ~/.factorio/scenarios/player_sync --plugins player_sync
#### `config list`
Lists up all configuration entries with their currently configured values.
+66 -38
View File
@@ -12,10 +12,11 @@
* @example
* npx clusteriohost run
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import yargs from "yargs";
import packageConfig from "@clusterio/host/package.json" with { type: "json" };
import setBlocking from "set-blocking";
import { version } from "./package.json";
import winston from "winston";
import "winston-daily-rotate-file";
@@ -23,8 +24,7 @@ import "winston-daily-rotate-file";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host.js";
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
import Host from "./src/Host";
let host: Host | undefined;
@@ -70,7 +70,7 @@ export class HostConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.hostConfig.get("host.controller_token"),
packageConfig.version,
version,
this.hostConfig.get("host.id"),
plugins,
)
@@ -83,7 +83,7 @@ export class HostConnector extends lib.WebSocketClientConnector {
async function startHost() {
// argument parsing
// eslint-disable-next-line node/no-sync
const args = yargs(process.argv.slice(2))
const args = yargs
.scriptName("host")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -105,12 +105,6 @@ async function startHost() {
default: "config-host.json",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -120,11 +114,6 @@ async function startHost() {
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command(
"create-scenario <output>",
"Create a scenario patched with modules for use outside Clusterio",
createScenarioCommand,
)
.command("run", "Run host", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
@@ -157,7 +146,7 @@ async function startHost() {
let command = args._[0];
if (command === "run") {
logger.info(`Starting Clusterio host ${packageConfig.version}`);
logger.info(`Starting Clusterio host ${version}`);
if (args.recovery) {
logger.warn("Host recovery mode enabled. Some features will be disabled.");
}
@@ -175,37 +164,44 @@ async function startHost() {
logger.info("Loading Plugin info");
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
if (!args.recovery) {
await lib.loadPluginMigrations(pluginInfos);
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
const [hostConfig, hostConfigLock] = await lib.loadConfigFromArgs(
args,
lib.HostConfig,
"host",
);
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
hostConfig.set("host.version", packageConfig.version); // Allows tracking last loaded version
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
hostConfig = new lib.HostConfig("host", undefined, hostConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
if (command === "create-scenario") {
await handleCreateScenarioCommand(args, hostConfig, pluginInfos);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true });
await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true });
await fs.mkdir("modules", { recursive: true });
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
await fs.ensureDir("modules");
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
@@ -243,8 +239,32 @@ async function startHost() {
);
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
@@ -267,6 +287,14 @@ async function startHost() {
}
export function bootstrap() {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startHost().catch(err => {
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
@@ -305,6 +333,6 @@ ${err.stack}`
});
}
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+7 -7
View File
@@ -1,10 +1,10 @@
import { bootstrap } from "./host.js";
export { BaseHostPlugin, HostHooks, type HostPluginContext } from "./src/BaseHostPlugin.js";
export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from "./src/BaseInstancePlugin.js";
export { default as Host, HostRouter } from "./src/Host.js";
export { default as Instance } from "./src/Instance.js";
export { default as InstanceConnection } from "./src/InstanceConnection.js";
import { bootstrap } from "./host";
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
export { default as Host, HostRouter } from "./src/Host";
export { default as Instance } from "./src/Instance";
export { default as InstanceConnection } from "./src/InstanceConnection";
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
@@ -1,29 +1,4 @@
---------------------------------------------------------------------------------------------------
Version: 2.1.21
Date: 2026-10-14
Changes:
- Removed the obsolete alpha label and development warning from the mod title and description.
---------------------------------------------------------------------------------------------------
Version: 2.1.20
Date: 2026-10-14
Changes:
- Removed the obsolete alpha label and development warning from the mod title and description.
---------------------------------------------------------------------------------------------------
Version: 2.1.18
Date: 2026-10-14
Changes:
- Removed the obsolete alpha label and development warning from the mod title and description.
---------------------------------------------------------------------------------------------------
Version: 2.1.17
Date: 2026-10-14
Changes:
- Removed the obsolete alpha label and development warning from the mod title and description.
---------------------------------------------------------------------------------------------------
Version: 2.1.11
Date: 2026-10-14
Changes:
- Removed the obsolete alpha label and development warning from the mod title and description.
---------------------------------------------------------------------------------------------------
Version: 2.0.20
Date: 25. 02. 2025
Features:
+6 -10
View File
@@ -2,30 +2,26 @@
"name": "clusterio_lib",
"variants": [
{
"version": "2.1.17",
"version": "2.0.17",
"factorio_version": "0.17"
},
{
"version": "2.1.18",
"version": "2.0.18",
"factorio_version": "0.18"
},
{
"version": "2.1.11",
"version": "2.0.11",
"factorio_version": "1.1"
},
{
"version": "2.1.20",
"version": "2.0.20",
"factorio_version": "2.0"
},
{
"version": "2.1.21",
"factorio_version": "2.1"
}
],
"title": "Clusterio Library",
"title": "Clusterio Library (Alpha)",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/clusterio",
"description": "Library for interfacing with Clusterio.",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"compat.lua": ["..", "..", "modules", "clusterio", "compat.lua"],
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"],
-4
View File
@@ -1,6 +1,3 @@
--- Undefined fields are expected, the typedefs only cover the latest version
--- @diagnostic disable: undefined-field
local function send_json(channel, data)
data = helpers and helpers.table_to_json(data) or game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
@@ -12,5 +9,4 @@ script.on_init(function()
for name, mod_setting in pairs(mod_settings) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
send_json("defines", defines)
end)
+2 -25
View File
@@ -10,16 +10,13 @@ Implementation Guidelines
local lib_json = require("json")
--- Undefined fields are expected, the typedefs only cover the latest version
--- @diagnostic disable: deprecated, undefined-field
--- @diagnostic disable: deprecated
--- @class LibCompat
--- @field script_data table
--- @field prototypes LuaPrototypes
--- @field active_mods table<string, string>
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
--- @field encode_string fun(string: string): string
--- @field decode_string fun(string: string): string
--- @field table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
@@ -180,24 +177,6 @@ function runtime_properties.write_file()
end
end
--- Deflates and base64 encodes the given string.
function runtime_properties.encode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.encode_string
elseif v2 then
return helpers.encode_string
end
end
--- Base64 decodes and inflates the given string.
function runtime_properties.decode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.decode_string
elseif v2 then
return helpers.decode_string
end
end
--- Convert tables to and from json, we have backported this to work before runtime
if v0 or v1 then
compat.table_to_json = function(tbl)
@@ -239,6 +218,4 @@ set_script_data()
compat.on_init = set_script_data --- @package
compat.on_load = set_script_data --- @package
-- Not returned directly because that would infer the metatable type rather than LibCompat
setmetatable(compat, compat_mt)
return compat
return setmetatable(compat, compat_mt)
-3
View File
@@ -22,9 +22,6 @@
-- SOFTWARE.
--
--- Vendored file, the forward declared recursion is not worth annotating
--- @diagnostic disable: need-check-nil
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
+22 -33
View File
@@ -1,28 +1,9 @@
{
"name": "@clusterio/host",
"version": "2.0.2",
"description": "Implementation of Clusterio host server",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -32,29 +13,37 @@
"bin": {
"clusteriohost": "./dist/node/host.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.1",
"jszip": "catalog:",
"pidusage": "^4.0.1",
"rcon-client": "^4.2.5",
"semver": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"yargs": "catalog:"
"jimp": "^0.22.8",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/pidusage": "^2.0.5",
"@types/semver": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
}
+43 -77
View File
@@ -1,24 +1,45 @@
import type Host from "./Host.js";
import * as lib from "@clusterio/lib";
export type HostPluginContext = lib.PluginLoadContext<{
host: Host;
}>;
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Host from "./Host";
/**
* Collection of host plugin hooks
* Base class for host plugins
*
* Host plugins are subclasses of this class which get instantiated by
* the host when it starts up with the plugin enabled in the config. To be
* discovered the class must be exported under the name `HostPlugin` in the
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
*/
export class HostHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.metrics = this.newHook();
this.shutdown = this.newHook();
this.hostConfigFieldChanged = this.newHook();
this.controllerConnectionEvent = this.newHook();
this.prepareControllerDisconnect = this.newHook();
export default class BaseHostPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Host the plugin started for
*/
public host: Host,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a host config field changed.
*
@@ -29,7 +50,7 @@ export class HostHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
readonly hostConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -46,12 +67,12 @@ export class HostHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called when the host is shutting down
*/
readonly shutdown: lib.AsyncHook<[]>;
*/
async onShutdown() { }
/**
* Called when an event on the controller connection happens
@@ -87,7 +108,7 @@ export class HostHooks extends lib.AsyncHookCollection {
*
* @param event - one of connect, drop, resume and close
*/
readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>;
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when the controller is preparing to disconnect from the host
@@ -102,60 +123,5 @@ export class HostHooks extends lib.AsyncHookCollection {
* @param connection -
* The connection to the host preparing to disconnect.
*/
readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Host]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseHostPlugin {
onHostConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Host): Promise<void>;
}
/**
* Base class for host plugins
*
* Host plugins are subclasses of this class which get instantiated by
* the host when it starts up with the plugin enabled in the config. To be
* discovered the class must be exported under the name `HostPlugin` in the
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseHostPlugin {
constructor(
public info: lib.PluginNodeEnvInfo,
public host: Host,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(host.hooks.hostConfigFieldChanged, this.onHostConfigFieldChanged);
attach(host.hooks.metrics, this.onMetrics);
attach(host.hooks.shutdown, this.onShutdown);
attach(host.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
attach(host.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
}
static fromContext(context: HostPluginContext): BaseHostPlugin {
const plugin = new this(context.plugin, context.host, context.logger);
return plugin;
}
detachHooks() {
this.host.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async onPrepareControllerDisconnect(connection: Host) { }
}
+70 -120
View File
@@ -1,30 +1,69 @@
import type Instance from "./Instance.js";
import type Host from "./Host.js";
import * as lib from "@clusterio/lib";
export type InstancePluginContext = lib.PluginLoadContext<{
host: Host;
instance: Instance;
}>;
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Instance from "./Instance";
import type Host from "./Host";
/**
* Collection of instance plugin hooks
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the host when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's `plugin` export.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
*/
export class InstanceHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.metrics = this.newHook();
this.start = this.newHook();
this.stop = this.newHook();
this.exit = this.newHook();
this.output = this.newHook();
this.instanceConfigFieldChanged = this.newHook();
this.controllerConnectionEvent = this.newHook();
this.prepareControllerDisconnect = this.newHook();
this.playerEvent = this.newHook();
export default class BaseInstancePlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
private _pendingRconMessages: {
resolve: (result: string) => void,
reject: (err: Error) => void,
message: string,
expectEmpty: boolean,
}[] = [];
private _sendingRconMessages = false;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Instance the plugin started for
*/
public instance: Instance,
/**
* Host running the instance
*
* With the exepction of accessing the host's config you should
* avoid ineracting with the host object directly.
*/
public host: Host,
) {
this.logger = instance.logger.child({ plugin: this.info.name }) as unknown as Logger;
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a config field changed.
*
@@ -35,7 +74,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
readonly instanceConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -52,12 +91,12 @@ export class InstanceHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called after the Factorio server is started
*/
readonly start: lib.AsyncHook<[]>;
async onStart() { }
/**
* Called before the Factorio server is stopped
@@ -65,7 +104,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
* This will not be called if for example the Factorio server crashes or
* is killed.
*/
readonly stop: lib.AsyncHook<[]>;
async onStop() { }
/**
* Called when the instance exits
@@ -74,7 +113,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
* has been called if an error occurs during startup. Note that if
* the plugin's init() throws this method will still be invoked.
*/
readonly exit: lib.AsyncHook<[]>;
onExit() { }
/**
* Called when the Factorio outputs a line
@@ -86,7 +125,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
* @param parsed - parsed server output.
* @param line - raw line of server output.
*/
readonly output: lib.AsyncHook<[parsed: lib.ParsedFactorioOutput, line: string]>;
async onOutput(parsed: ParsedFactorioOutput, line: string) { }
/**
* Called when an event on the controller connection happens
@@ -122,7 +161,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
*
* @param event - one of connect, drop, resume and close
*/
readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>;
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when the controller is preparing to disconnect from the host
@@ -137,7 +176,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
* @param connection -
* The connection to the host preparing to disconnect.
*/
readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Instance]>;
async onPrepareControllerDisconnect(connection: Instance) { }
/**
* Called when a player joins or leaves the game
@@ -146,96 +185,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
*
* @param event - Information about the event.
*/
readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseInstancePlugin {
onInstanceConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onStart?(): Promise<void>;
onStop?(): Promise<void>;
onExit?(): void;
onOutput?(parsed: lib.ParsedFactorioOutput, line: string): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
onPlayerEvent?(event: lib.PlayerEvent): Promise<void>;
}
/**
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the host when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's `plugin` export.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseInstancePlugin {
private _pendingRconMessages: {
resolve: (result: string) => void,
reject: (err: Error) => void,
message: string,
expectEmpty: boolean,
}[] = [];
private _sendingRconMessages = false;
constructor(
/**
* The plugin's own info module
*/
public info: lib.PluginNodeEnvInfo,
/**
* Instance the plugin started for
*/
public instance: Instance,
/**
* Host running the instance
*
* With the exepction of accessing the host's config you should
* avoid ineracting with the host object directly.
*/
public host: Host,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(instance.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
attach(instance.hooks.metrics, this.onMetrics);
attach(instance.hooks.start, this.onStart);
attach(instance.hooks.stop, this.onStop);
attach(instance.hooks.exit, this.onExit);
attach(instance.hooks.output, this.onOutput);
attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
attach(instance.hooks.playerEvent, this.onPlayerEvent);
}
static fromContext(context: InstancePluginContext) {
return new this(context.plugin, context.instance, context.host, context.logger);
}
detachHooks() {
this.instance.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async onPlayerEvent(event: PlayerEvent) { }
/**
* Send RCON message to instance
+97 -196
View File
@@ -1,21 +1,21 @@
import dgram from "node:dgram";
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import events from "events";
import pidusage from "pidusage";
import semver from "semver";
import setBlocking from "set-blocking";
import stream from "stream";
import { fileURLToPath } from "node:url";
import util from "util";
// internal libraries
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { HostConnector } from "../host.js";
import Instance from "./Instance.js";
import InstanceConnection from "./InstanceConnection.js";
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
import type { HostConnector } from "../host";
import Instance from "./Instance";
import InstanceConnection from "./InstanceConnection";
import BaseHostPlugin from "./BaseHostPlugin";
const finished = util.promisify(stream.finished);
function checkRequestSaveName(name: string) {
@@ -26,35 +26,6 @@ function checkRequestSaveName(name: string) {
}
}
/**
* Checks that no socket is bound to the given UDP port, on IPv4 or IPv6
*
* Factorio binds the game port on both 0.0.0.0 and [::].
*
* @param port - UDP port to check.
* @returns true if the port can be bound.
* @internal
*/
async function udpPortFree(port: number) {
for (const type of ["udp4", "udp6"] as const) {
const socket = dgram.createSocket({ type, ipv6Only: type === "udp6" });
try {
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
socket.bind(port, resolve);
});
} catch (err: any) {
// Other errors, such as no IPv6 support on the host, don't stop Factorio from binding the port.
if (err.code === "EADDRINUSE") {
return false;
}
} finally {
socket.close();
}
}
return true;
}
/**
* Searches for instances in the provided directory
*
@@ -211,12 +182,6 @@ export class HostRouter {
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.host) {
// Sent up to the controller, which passes it on to the other
// hosts. Nothing left to do when it came from there.
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.control) {
if (this.host !== origin) {
if (!plugin || this.host.serverPlugins.has(plugin)) {
@@ -248,7 +213,7 @@ export class HostRouter {
this.host._connectInstance(dst.id).then(instanceConnection => {
instanceConnection.connector.forward(message);
}).catch(err => {
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
logger.error(`Error starting instance:\n${err.stack}`, this.host.instanceLogMeta(dst.id));
origin.connector.sendResponseError(
new lib.ResponseError(err.message, err.code, err.stack), message.src
);
@@ -300,10 +265,8 @@ export default class Host extends lib.Link {
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** Hooks which plugins can attach to */
hooks = new HostHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseHostPlugin> = new Map();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
@@ -331,7 +294,7 @@ export default class Host extends lib.Link {
static async bootstrap(hostConfig: lib.HostConfig) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
await fs.ensureDir(modsDirectory);
return [
await lib.ModStore.fromDirectory(modsDirectory),
] as const;
@@ -362,7 +325,7 @@ export default class Host extends lib.Link {
if (name === "host.name" || name === "host.public_address") {
this.sendHostUpdate();
}
this.hooks.hostConfigFieldChanged.invoke(name, curr, prev);
lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev);
});
this.connector.on("hello", data => {
@@ -414,16 +377,16 @@ export default class Host extends lib.Link {
for (let instanceConnection of this.instanceConnections.values()) {
instanceConnection.send(message);
}
this.hooks.controllerConnectionEvent.invoke(event);
for (let plugin of this.plugins.values()) {
plugin.onControllerConnectionEvent(event);
}
});
}
this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this));
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostMarkRestartRequiredRequest, this.handleHostMarkRestartRequiredRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
@@ -432,7 +395,6 @@ export default class Host extends lib.Link {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
@@ -453,7 +415,6 @@ export default class Host extends lib.Link {
}
async loadPlugins() {
const context = { logger, host: this };
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
@@ -467,16 +428,20 @@ export default class Host extends lib.Link {
continue;
}
let HostPluginClass = BaseHostPlugin;
try {
await lib.loadPlugin(
pluginInfo,
"host",
context,
"HostPlugin",
BaseHostPlugin,
);
if (pluginInfo.hostEntrypoint) {
HostPluginClass = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint),
"HostPlugin",
BaseHostPlugin,
);
}
this.loadedPlugins.add(pluginInfo);
let hostPlugin = new HostPluginClass(pluginInfo, this, logger);
await hostPlugin.init();
this.plugins.set(pluginInfo.name, hostPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
@@ -530,61 +495,26 @@ export default class Host extends lib.Link {
if (!this.canRestart) {
throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it.");
}
const downgrade = await this.checkRestartDowngrade();
if (downgrade) {
const { installedVersion, runningVersion } = downgrade;
throw new lib.RequestError(
`Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` +
`running version ${runningVersion}. Stop the host before starting the older version manually.`
);
}
process.exitCode = 1;
this.shutdown();
}
async handleHostMarkRestartRequiredRequest() {
this.config.restartRequired = true;
}
async handleHostConfigGetRequest() {
return this.config.toRemote("control");
}
private static validateHostConfigSetField(fieldName: string) {
if (fieldName === "host.id") {
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
}
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
Host.validateHostConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
}
} else {
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
}
}
this.config.commitStaging();
} finally {
this.config.revertStaging();
}
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
Host.validateHostConfigSetField(request.field);
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
Host.validateHostConfigSetField(field);
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
@@ -661,10 +591,19 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new Error(`Empty body downloading stream ${streamId} from controller`);
}
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
await lib.downloadFile(url, filePath, "overwrite");
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
await events.once(writeStream, "open");
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
const modInfo = await this.modStore.loadFile(file);
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
@@ -705,8 +644,10 @@ export default class Host extends lib.Link {
return await this.downloadMod(mod);
}
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
async fetchMods(mods: Iterable<lib.ModRecord>) {
// This is better than the previous hard coded names
// But it really shouldn't be a hard coded version either
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
if (builtinModNames.includes(mod.name)) {
@@ -821,11 +762,8 @@ export default class Host extends lib.Link {
);
}
assignGamePort(instanceId: number, inUse = new Set<number>()) {
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const port of inUse) {
availablePorts.delete(port);
}
for (const [id, instance] of this.assignedInstances) {
if (id === instanceId) {
continue;
@@ -844,36 +782,13 @@ export default class Host extends lib.Link {
return assignedPort;
}
if (!availablePorts.size) {
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
throw new lib.RequestError(`No available port left to assign from host.factorio_port_range${others}`);
throw new lib.RequestError("No available port left to assign from host.factorio_port_range");
}
const [newPort] = availablePorts;
instanceToAssign.config.set("factorio.host_assigned_game_port", newPort);
return newPort;
}
/**
* Assign a game port that no other process is bound to
*
* Replaces the stored port if it is taken, so a port another process
* holds is not reused on every start.
*
* @param instanceId - ID of the instance to assign a game port to.
* @returns the assigned port.
*/
async assignFreeGamePort(instanceId: number) {
const inUse = new Set<number>();
for (;;) {
// Picking and storing the port before checking it keeps concurrent assignments from picking the same one.
const port = this.assignGamePort(instanceId, inUse);
if (await udpPortFree(port)) {
return port;
}
logger.warn(`Game port ${port} is in use by another process`, this.instanceLogMeta(instanceId));
inUse.add(port);
}
}
/**
* Initialize and connect an unloaded instance
*
@@ -916,8 +831,8 @@ export default class Host extends lib.Link {
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -933,44 +848,20 @@ export default class Host extends lib.Link {
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
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}`);
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("host.version");
const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
if (!semver.valid(runningVersion) || !semver.valid(installedVersion)) {
logger.warn(
`Unable to compare running host version ${runningVersion} ` +
`with installed version ${installedVersion}.`
);
return null;
}
if (semver.lt(installedVersion, runningVersion)) {
return { installedVersion, runningVersion };
}
} catch (err: any) {
logger.warn(`Failed to check host version before restart:\n${err.stack ?? err.message}`);
}
return null;
}
async handleHostMetricsRequest() {
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
for (let instanceConnection of this.instanceConnections.values()) {
@@ -978,7 +869,7 @@ export default class Host extends lib.Link {
}
let results = [];
let pluginResults = await this.hooks.metrics.collect();
let pluginResults = await lib.invokeHook(this.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -1025,17 +916,12 @@ export default class Host extends lib.Link {
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
try {
// Link save in the new location.
await fs.link(
await fs.move(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
{ overwrite: false },
);
// Unlink save in old location.
await fs.unlink(path.join(instance.path, "saves", oldName));
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.RequestError(`${newName} already exists`);
}
if (err.code === "ENOENT") {
throw new lib.RequestError(`${oldName} does not exist`);
}
@@ -1050,10 +936,10 @@ export default class Host extends lib.Link {
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
try {
await fs.copyFile(
await fs.copy(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
{ overwrite: false, errorOnExist: true },
);
} catch (err: any) {
if (err.code === "ENOENT") {
@@ -1079,15 +965,16 @@ export default class Host extends lib.Link {
try {
if (copy) {
await fs.copyFile(
await fs.copy(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
fs.constants.COPYFILE_FICLONE
{ overwrite: true },
);
} else {
await fs.rename(
await fs.move(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
);
}
} catch (err: any) {
@@ -1137,9 +1024,35 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
}
let savesDir = path.join(instance.path, "saves");
const savedPath = await lib.downloadFile(url, path.join(savesDir, name), "rename");
name = path.basename(savedPath);
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
try {
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name));
await this.sendSaveListUpdate(instanceId, savesDir);
return name;
}
@@ -1149,9 +1062,11 @@ export default class Host extends lib.Link {
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let content;
let content = fs.createReadStream(path.join(instance.path, "saves", name));
try {
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
// "open" with "ready" when fs-extra is replaced with node's fs module.
await events.once(content, "open");
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1183,7 +1098,7 @@ export default class Host extends lib.Link {
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.rm(instance.path, { recursive: true, maxRetries: 10 });
await fs.remove(instance.path);
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
@@ -1204,27 +1119,13 @@ export default class Host extends lib.Link {
if (!this.config.get("host.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
await lib.handlePluginInstall(request.pluginPackage);
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
this.config.restartRequired = true;
}
}
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
if (!this.config.get("host.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
return await lib.handlePluginInstall(request.pluginPackage);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.loadedPlugins.has(pluginInfo),
this.plugins.has(pluginInfo.name),
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1285,7 +1186,7 @@ export default class Host extends lib.Link {
}
async prepareDisconnect() {
await this.hooks.prepareControllerDisconnect.invoke(this);
await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this);
for (let instanceConnection of this.instanceConnections.values()) {
await instanceConnection.send(new lib.PrepareControllerDisconnectRequest());
}
@@ -1302,7 +1203,7 @@ export default class Host extends lib.Link {
}
this._shuttingDown = true;
await this.hooks.shutdown.invoke();
await lib.invokeHook(this.plugins, "onShutdown");
for (let instanceConnection of this.instanceConnections.values()) {
try {
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff. Show more