Compare commits

..
Author SHA1 Message Date
Cursor Agentanddanielv f6424c5411 Add Factorio version management and download functionality
Co-authored-by: danielv <danielv@danielv.no>
2025-07-21 18:47:19 +00:00
252 changed files with 7209 additions and 16076 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
dist/
+185 -274
View File
@@ -1,47 +1,118 @@
import {
defineConfig,
globalIgnores,
} from "eslint/config";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import node from "eslint-plugin-node";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import {
FlatCompat,
} from "@eslint/eslintrc";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([{
plugins: {
"@typescript-eslint": typescriptEslint,
"@stylistic": stylistic,
node,
"use strict";
module.exports = {
"plugins": [
"@typescript-eslint",
"node",
],
"root": true,
"env": {
"node": true,
"commonjs": true,
"es2022": true,
},
languageOptions: {
globals: {
...globals.node,
...globals.commonjs,
"overrides": [
{
"files": ["{packages,plugins}/**/*.ts?(x)"],
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"prefer-const": "off",
"no-shadow": "off",
},
},
},
{
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
"env": {
"mocha": "true",
},
"parserOptions": { sourceType: "commonjs" },
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"valid-jsdoc": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
"files": [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
"env": {
"browser": true,
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
{
"files": [
"packages/create/templates/**/*.{t,j}s?(x)",
],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
},
],
"rules": {
"@stylistic/indent": ["error", "tab", {
"ignoredNodes": [
"JSXFragment", // TODO: Linter crashes if this is removed
],
"ignoreComments": true,
"SwitchCase": 1, // Indent case statements one level from switch
}],
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": ["error", "never"],
@@ -49,34 +120,26 @@ export default defineConfig([{
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "off",
"arrow-spacing": ["error", {
"after": true,
"before": true,
}],
"arrow-spacing": ["error", { "after": true, "before": true }],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", {
"allowSingleLine": true,
}],
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
}],
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
},
],
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": ["error", { "max": 25 }],
"complexity": "error",
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
@@ -85,36 +148,27 @@ export default defineConfig([{
"default-case-last": "error",
"default-param-last": "off",
"dot-location": ["error", "property"],
"dot-notation": ["error", {
"allowPattern": "^[a-z]+(_[a-z]+)*$",
}],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration", {
// Allow arrow functions as it better expresses referencing this
// of the containing scope, which is unweilding in plain functions.
"allowArrowFunctions": true,
}],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", {
"before": false,
"after": true,
}],
"generator-star-spacing": ["error", { "before": false, "after": true }],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent": ["error", "tab", { "SwitchCase": 1 }],
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
@@ -124,18 +178,10 @@ export default defineConfig([{
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always", {
"exceptAfterSingleLine": true,
}],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": ["error", {
"code": 120,
}],
"max-len": ["error", { "code": 120 }],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
@@ -144,12 +190,10 @@ export default defineConfig([{
"max-statements-per-line": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
@@ -163,11 +207,7 @@ export default defineConfig([{
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-console": "error",
"no-constant-condition": ["error", {
"checkLoops": false,
}],
"no-constant-condition": ["error", { "checkLoops": false }],
"no-constructor-return": "error",
"no-continue": "off",
"no-div-regex": "error",
@@ -192,15 +232,16 @@ export default defineConfig([{
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-mixed-operators": ["error", {
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
}],
"no-mixed-operators": [
"error",
{
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
},
],
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -215,11 +256,7 @@ export default defineConfig([{
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-plusplus": ["error", {
"allowForLoopAfterthoughts": true,
}],
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
@@ -231,15 +268,9 @@ export default defineConfig([{
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"no-spaced-func": "error",
"no-tabs": "off",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
@@ -258,11 +289,7 @@ export default defineConfig([{
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": ["error", {
"allowAsStatement": true,
}],
"no-void": ["error", { "allowAsStatement": true }],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
@@ -272,7 +299,6 @@ export default defineConfig([{
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": ["error", "after", {
"overrides": {
"?": "before",
@@ -282,7 +308,6 @@ export default defineConfig([{
"??": "before",
},
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
@@ -299,13 +324,9 @@ export default defineConfig([{
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", {
"avoidEscape": true,
}],
"quotes": ["error", "double", { "avoidEscape": true }],
"radix": ["error", "always"],
"require-atomic-updates": "off",
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
@@ -321,183 +342,73 @@ export default defineConfig([{
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["error", "always", {
"line": {
"markers": ["/"],
},
}],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", {
"requireReturn": false,
"requireReturnType": false,
"requireParamType": false,
}],
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", {
"exceptRange": true,
}],
"yoda": ["error", "never", { "exceptRange": true }],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
// Node.js callback passing style of (err, value) is not used in this project
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-missing-import": ["error", {
"tryExtensions": [".js", ".ts"],
}],
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["{packages,plugins}/**/*.ts?(x)"],
languageOptions: {
parser: tsParser,
},
extends: compat.extends("plugin:@typescript-eslint/recommended"),
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "off",
"no-shadow": "off",
},
}, {
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
languageOptions: {
globals: {
...globals.mocha,
},
sourceType: "commonjs",
parserOptions: {},
},
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
}, {
files: [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
languageOptions: {
globals: {
...globals.browser,
},
"sourceType": "module",
parserOptions: {
"ecmaFeatures": {
"jsx": true,
"node/process-exit-as-throw": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
},
},
}],
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"@stylistic/indent": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
}, globalIgnores(["**/dist/", "dist/"])]);
};
+8 -8
View File
@@ -20,6 +20,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@@ -40,21 +42,17 @@ jobs:
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -63,9 +61,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 18.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
-44
View File
@@ -1,44 +0,0 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+8 -3
View File
@@ -29,13 +29,18 @@ dist/
node_modules
/factorio*
Factorio*
/factorio
!/test/file/factorio
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
/mods
/database
/plugin-list.json
/config-*.json
/config-*.json.lock
/config-controller.json
/config-control.json
/config-host.json
pnpm-lock.yaml
mod_cache.json
/static/
-105
View File
@@ -31,111 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.23
### Major Features
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
### Features
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce 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 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 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. [#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. [#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. [#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).
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
### Breaking Changes
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Daniel](https://github.com/Danielv123)
[@Double](https://github.com/DoubleThoughtTheProgrammer)
[@Hornwitser](https://github.com/Hornwitser)
[@Ryan Walker](https://github.com/solarcloud7)
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
+2 -2
View File
@@ -146,7 +146,7 @@ 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 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.
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.
### MacOS Setup
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation and creating your admin user, start up the controller and at least one host separately.
After completing the installation start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
+5 -8
View File
@@ -47,9 +47,6 @@ async function removeBuildArtifacts() {
tasks.push(
tryRemove(path.join(basePath, packageName, "dist"))
);
tasks.push(
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
);
}
}
@@ -84,10 +81,10 @@ async function removeExecutionArtifacts() {
* Main function for this script
*/
async function main() {
await yargs(process.argv.slice(2))
await yargs
.scriptName("clean")
.option("dry-run", {
alias: "dry",
.usage("$0 <command> [options]")
.option("dry", {
nargs: 0,
describe: "Will not delete any files",
default: false,
@@ -104,11 +101,11 @@ async function main() {
removeBuildArtifacts();
removeTestArtifacts();
})
.command("$0", "Remove all build artifacts", () => {}, argv => {
.command("fast", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "A command must be specified")
.demandCommand(1, "You need to specify a command to run")
.strict()
.parse();
}
+16 -8
View File
@@ -182,6 +182,14 @@ You can generate an access token with `clusterioctl host generate-token --id <ho
Defaults to "enter token here".
### host.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### host.public_address
External address instances hosted on this host can be accessed on.
@@ -231,14 +239,6 @@ Does not affect start or stop of the instance while the host is running.
Defaults to false.
### instance.exclude_from_start_all
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
This allows you to prevent certain instances from being started when using the bulk start operation.
Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
@@ -405,6 +405,14 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
+1 -1
View File
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
## Communicating with Clusterio
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [protocol.md](devs/protocol.md) for the implementation details of it.
See [socket.md](socket.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
+4 -19
View File
@@ -63,25 +63,10 @@ This creates a private key file named `key.pem` and a public certificate file na
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
**For run-host.cmd**
npx clusteriohost config set host.tls_ca cert.pem
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
**For run-host.sh**
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
**For clusteriohost.service**
Environment=NODE_EXTRA_CA_CERTS=path/to/cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
# On Windows
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
# On Linux
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
# You can now use clusterioctl
npx clusterioctl instance list
npx clusterioctl control-config set control.tls_ca cert.pem
+20 -22
View File
@@ -2,12 +2,11 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
@@ -15,7 +14,9 @@
"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",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -26,7 +27,7 @@
]
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"workspaces": [
"packages/*",
@@ -40,24 +41,21 @@
"@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",
"@swc/core": "^1.11.1",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.57.1",
"eslint-plugin-node": "^11.1.0",
"express": "^5.1.0",
"form-data": "^4.0.4",
"express": "^4.21.2",
"form-data": "^4.0.2",
"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"
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.0",
"mocha": "^10.8.2",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.26.11",
"typescript": "^5.7.3",
"yargs": "^17.7.2"
}
}
+18 -46
View File
@@ -32,7 +32,6 @@ const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
// globals
@@ -128,45 +127,35 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
controllerConfig: lib.ControllerConfig
): Promise<void> {
let subCommand = args._[1];
const databaseDirectory = controllerConfig.get("controller.database_directory");
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
).bootstrap());
const userManager = new UserManager(users, roles, controllerConfig);
const userManager = new UserManager(controllerConfig, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
const user = userManager.getOrCreateUser(args.name);
user.roleIds.add(lib.Role.DefaultAdminRoleId);
user.isAdmin = true;
user.saveRecord();
let admin = userManager.getByName(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
await userManager.records.save();
await controllerConfigLock.release();
let adminRole = lib.ensureDefaultAdminRole(roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
} else if (subCommand === "generate-user-token") {
let user = userManager.getByName(args.name);
@@ -262,7 +251,6 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
@@ -293,7 +281,6 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -363,14 +350,8 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
const controllerConfigPath = args.config;
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
@@ -396,11 +377,10 @@ async function initialize(): Promise<InitializeParameters> {
}
if (command === "config") {
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
await lib.handleConfigCommand(args, controllerConfig);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
await handleBootstrapCommand(args, controllerConfig);
}
return {
@@ -437,20 +417,12 @@ async function startup() {
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.users.records.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -463,7 +435,7 @@ async function startup() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -490,7 +462,7 @@ I version of clusterio. Expect things to break. I
`
);
startup().catch(err => {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
if (err instanceof lib.StartupError) {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
+3 -5
View File
@@ -1,15 +1,13 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceRecord } from "./src/InstanceRecord";
export { default as InstanceManager } from "./src/InstanceManager";
export { default as HostRecord } from "./src/HostRecord";
export { default as InstanceInfo } from "./src/InstanceInfo";
export { default as HostInfo } from "./src/HostInfo";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as UserRecord } from "./src/UserRecord";
export { default as User } from "./src/User";
export { default as WsServer } from "./src/WsServer";
if (module === require.main) {
+30 -30
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -17,45 +17,45 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"@sinclair/typebox": "^0.30.4",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"express": "^4.21.2",
"finalhandler": "^1.3.1",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"@types/compression": "^1.7.2",
"@swc/core": "^1.11.1",
"@types/busboy": "^1.5.0",
"@swc/core": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.29.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
},
"publishConfig": {
"access": "public"
+2 -7
View File
@@ -16,7 +16,7 @@ import * as routes from "./routes";
* @alias module:controller/src/BaseConnection
*/
export default class BaseConnection extends lib.Link {
declare "connector": WsServerConnector;
declare ["connector"]: WsServerConnector;
constructor(
connector: lib.WebSocketBaseConnector,
@@ -32,7 +32,6 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -79,7 +78,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
getMod(mod: { name: string, version: string, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -107,8 +106,4 @@ export default class BaseConnection extends lib.Link {
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
@@ -3,7 +3,7 @@ import type {
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceRecord from "./InstanceRecord";
import type InstanceInfo from "./InstanceInfo";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
@@ -91,7 +91,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -117,7 +117,7 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -261,7 +261,7 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
+96 -241
View File
@@ -6,8 +6,7 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -41,7 +40,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: User, // The user making this connection.
public user: ControllerUser, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -101,8 +100,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -127,6 +124,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -250,7 +248,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
@@ -268,20 +266,20 @@ export default class ControlConnection extends BaseConnection {
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instances.createInstance(instanceConfig);
await this._controller.instanceCreate(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instances.deleteInstance(request.instanceId);
await this._controller.instanceDelete(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
@@ -296,14 +294,14 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
let { field, prop, value } = request;
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
await this._controller.instanceAssign(request.instanceId, request.hostId);
}
async handleInstanceSaveDetailsListRequest() {
@@ -337,8 +335,8 @@ export default class ControlConnection extends BaseConnection {
if (request.sourceInstanceId === request.targetInstanceId) {
throw new lib.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
@@ -566,205 +564,52 @@ export default class ControlConnection extends BaseConnection {
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
const factorioVersion = request.factorioVersion;
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
const { name, version, factorioVersion } = request;
const filename = lib.ModInfo.filename(name, version);
if (toDownload.length > 0) {
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || username === "" || !token || token === "") {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
} catch (error: any) {
logger.error(`Error downloading mods from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = "Mod portal download failed";
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
// Check if mod already exists
if (this._controller.modStore.files.has(filename)) {
logger.verbose(`Mod ${filename} already exists, skipping download.`);
return this._controller.modStore.files.get(filename)!;
}
// Verify all mods are present
const modInfos = [];
const installedMods = [...this._controller.modStore.mods()];
for (const mod of request.mods) {
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
if (!modInfo) {
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
}
modInfos.push(modInfo);
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || !token) {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
return modInfos;
}
try {
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
const modMap = new Map([[name, version]]);
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
// eslint-disable-next-line complexity
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const skipSearch = new Set(builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
let releases = _modPortalReleases.get(modName);
if (releases === undefined) {
try {
releases = await lib.ModStore.fetchModReleases(modName);
} catch (error: any) {
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
logger.error(`Error fetching mod from portal: ${error.message}`);
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
} else {
releases = null;
}
}
// Verify download and return ModInfo
const downloadedMod = this._controller.modStore.files.get(filename);
if (!downloadedMod) {
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
}
_modPortalReleases.set(modName, releases);
return releases;
}
logger.info(`Successfully downloaded mod ${filename}.`);
return downloadedMod;
function selectModRelease(releases: ModPortalModType | null, versionRange: lib.ModVersionRange) {
if (!releases || !releases.releases) {
return null;
}
// Select the latest matching version
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
return null;
}
// Create the mod info class from the selected
try {
return lib.ModInfo.fromJSON({
...release.info_json, // includes dependencies
sha1: release.sha1,
version: release.version,
name: releases.name,
author: releases.owner,
title: releases.title,
});
} catch {
// Can error if there are invalid dependencies
errors.set(releases.name, "invalidDependency");
return null;
}
}
while (searchQueue.length > 0) {
// Dequeue the next mod, skip if incompatible
const mod = searchQueue.shift()!;
if (mod.incompatible) {
errors.set(mod.name, "incompatible");
continue;
}
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
}
// Update the version range if needed
if (mod.version) {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
}
}
// Do not add dependencies for errors, skips, optionals, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (errors.has(mod.name) || skipSearch.has(mod.name) || optionalRequirements.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
}
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const releases = await fetchModReleases(mod.name);
const release = selectModRelease(releases, versionRange);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || release.integerVersion > candidate.integerVersion)) {
candidate = release;
}
}
// Queue the dependencies of the new candidate
if (candidate) {
candidateReleases.set(mod.name, candidate);
for (const dep of candidate.dependencies) {
searchQueue.push(dep);
}
} catch (error: any) {
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = `Mod portal download failed for ${name} v${version}`;
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
candidateReleases.delete(mod.name);
skipSearch.add(mod.name);
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
// Add the not found dependencies as errors
for (const modName of dependencyRequirements.keys()) {
if (!candidateReleases.has(modName) && !errors.has(modName)) {
errors.set(modName, "notFound");
}
}
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()], errors,
);
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
@@ -870,33 +715,35 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.roles.delete(role);
for (const user of this._controller.users.values()) {
if (user.removeRole(id)) {
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
let name = request.name;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user.toUserDetails();
return user;
}
async handleUserListRequest(): Promise<lib.UserDetails[]> {
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
this._controller.users.createUser(request.name);
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.users.getByName(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -910,10 +757,11 @@ export default class ControlConnection extends BaseConnection {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.users.getByName(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -924,33 +772,35 @@ export default class ControlConnection extends BaseConnection {
}
}
user.set("roleIds", new Set(request.roles));
user.roleIds = new Set(request.roles);
this._controller.userPermissionsUpdated(user);
this._controller.usersUpdated([user]);
}
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
let { name, create, admin } = request;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.set("isAdmin", admin);
user.isAdmin = admin;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
}
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.users.getByNameMutable(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
@@ -958,34 +808,37 @@ export default class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
user.saveRecord();
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
}
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.set("isWhitelisted", whitelisted);
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
this._controller.users.deleteUser(user);
user.isDeleted = true;
this._controller.userManager.users.delete(name);
this._controller.usersUpdated([user]);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -998,15 +851,14 @@ export default class ControlConnection extends BaseConnection {
}
}
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
if (request.importType === "users") {
// Update all fields for all users
const users = request.users as Extract<typeof request.users, { username: string }[]>;
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
for (const user of this._controller.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
@@ -1027,7 +879,7 @@ export default class ControlConnection extends BaseConnection {
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
@@ -1039,7 +891,7 @@ export default class ControlConnection extends BaseConnection {
const users = request.users as string[];
const expected = new Set(users.map(u => u.toLowerCase()));
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
@@ -1048,9 +900,11 @@ export default class ControlConnection extends BaseConnection {
}
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
// eslint-disable-next-line complexity
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<User["id"], User>();
const updated = new Map<ControllerUser["id"], ControllerUser>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
@@ -1058,12 +912,11 @@ export default class ControlConnection extends BaseConnection {
}
// Will get a user or attempt to create one
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
const getUserOrCreate = (username: string) => {
const user = this._controller.users.getByNameMutable(username);
if (user) { return updated.get(user.id) ?? user; }
const user = this._controller.userManager.getByName(username);
if (user) { return user; }
this.user.checkPermission("core.user.create");
return this._controller.users.createUser(username);
return this._controller.userManager.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
@@ -1121,14 +974,14 @@ export default class ControlConnection extends BaseConnection {
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.users.records.setMany(updatedUsers);
this._controller.usersUpdated(updatedUsers);
// Resync the host user lists
const adminlist: Set<string> = new Set();
const banlist: Map<string, string> = new Map();
const whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -1152,7 +1005,7 @@ export default class ControlConnection extends BaseConnection {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.users.values()) {
for (const user of this._controller.userManager.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
@@ -1178,7 +1031,7 @@ export default class ControlConnection extends BaseConnection {
} else if (request.exportType === "bans") {
// Bans have extra logic to handle ban reason
const usersToSend = [] as Array<string | { username: string, reason: string }>;
for (const user of this._controller.users.values()) {
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
@@ -1193,7 +1046,7 @@ export default class ControlConnection extends BaseConnection {
// This is a whitelist or admins export
const usersToSend = [] as string[];
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.values()) {
for (const user of this._controller.userManager.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
@@ -1205,7 +1058,7 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return await lib.updatePackage("@clusterio/controller");
return lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1226,7 +1079,9 @@ export default class ControlConnection extends BaseConnection {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
this._controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
) as boolean,
));
}
+220 -145
View File
@@ -18,17 +18,16 @@ import * as lib from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import HttpCloser from "./HttpCloser";
import InstanceRecord from "./InstanceRecord";
import InstanceInfo from "./InstanceInfo";
import * as metrics from "./metrics";
import * as routes from "./routes";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostRecord from "./HostRecord";
import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -80,10 +79,6 @@ export default class Controller {
/** Event subscription controller */
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 */
instances: InstanceManager;
// Possible states are new, starting, running, stopping, stopped
private _state: string = "new";
@@ -101,7 +96,6 @@ export default class Controller {
devMiddleware: any | null = null;
startedAtMs = Date.now();
autosaveInterval?: ReturnType<typeof setInterval>;
systemMetricsInterval?: ReturnType<typeof setInterval>;
@@ -111,26 +105,11 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(async () => {
try {
return await lib.fetchFactorioVersions();
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio versions ${err.message}`);
return [];
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
@@ -139,13 +118,13 @@ export default class Controller {
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostRecord.fromJSON.bind(HostRecord),
HostInfo.fromJSON.bind(HostInfo),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
InstanceRecord.fromJSON.bind(InstanceRecord),
json => InstanceInfo.fromJSON(json, "controller"),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
@@ -156,8 +135,7 @@ export default class Controller {
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack),
this.migrateModPacks,
lib.ModPack.fromJSON.bind(lib.ModPack)
).bootstrap());
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
@@ -166,11 +144,8 @@ export default class Controller {
this.migrateRoles,
).bootstrap());
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
undefined, this.finaliseUsers,
).bootstrap());
const userManager = new UserManager(config, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
@@ -182,8 +157,8 @@ export default class Controller {
}
// Add default roles
lib.Role.ensureDefaultAdminRole(roles);
lib.Role.ensureDefaultPlayerRole(roles);
lib.ensureDefaultAdminRole(roles);
lib.ensureDefaultPlayerRole(roles);
return [
systems,
@@ -193,7 +168,7 @@ export default class Controller {
modPacks,
modStore,
roles,
users,
userManager,
] as const;
}
@@ -213,10 +188,10 @@ export default class Controller {
*/
public recoveryMode: boolean = false,
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** Mapping of host id to host record */
public hosts = new lib.SubscribableDatastore<HostRecord>(),
/** Mapping of instance id to instance record */
instances = new lib.SubscribableDatastore<InstanceRecord>(),
/** Mapping of host id to host info */
public hosts = new lib.SubscribableDatastore<HostInfo>(),
/** Mapping of instance id to instance info */
public instances = new lib.SubscribableDatastore<InstanceInfo>(),
/** Mapping of save id to save details */
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
/** Mapping of mod pack id to mod pack */
@@ -225,8 +200,8 @@ export default class Controller {
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
/** Mapping of mod pack id to mod pack */
public roles = new lib.SubscribableDatastore<lib.Role>(),
/** Mapping of user id to user record */
users = new lib.SubscribableDatastore<UserRecord>(),
/** User and roles manager for the cluster */
public userManager = new UserManager(config, roles),
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
@@ -239,9 +214,6 @@ export default class Controller {
this.trustedProxies = this.parseTrustedProxies();
this.wsServer = new WsServer(this);
this.users = new UserManager(users, roles, config);
this.instances = new InstanceManager(instances, this);
this.modStore.on("change", mod => {
this.modsUpdated([mod]);
});
@@ -261,11 +233,10 @@ export default class Controller {
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.bind(this));
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
this.roles.on("update", this.rolesUpdated.bind(this));
this.users.records.on("update", this.usersUpdated.bind(this));
}
async start(args: ControllerArgs) {
@@ -330,6 +301,9 @@ export default class Controller {
}
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
});
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
// Make sure we're actually going to listen on a port
let httpPort = this.config.get("controller.http_port");
@@ -420,18 +394,18 @@ export default class Controller {
this.onSystemMetricsIntervalChanged();
logger.info("Started controller");
this.startedAtMs = Date.now();
this._state = "running";
}
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
/* eslint-disable @typescript-eslint/no-var-requires, node/no-unpublished-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfigs = [];
if (args.dev) {
// eslint-disable-next-line node/no-missing-require
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
}
if (args.devPlugin) {
@@ -447,7 +421,7 @@ export default class Controller {
}
this.app.locals.devPlugins = devPlugins;
}
/* eslint-enable @typescript-eslint/no-var-requires, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
@@ -576,8 +550,19 @@ export default class Controller {
}
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("controller.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
}
requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -587,46 +572,6 @@ export default class Controller {
}
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/controller";
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -665,13 +610,16 @@ export default class Controller {
this.config.save(),
this.systems.save(),
this.hosts.save(),
this.instances.records.save(),
this.instances.save(),
this.saves.save(),
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
]);
if (this.userManager.dirty) {
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
}
await lib.invokeHook(this.plugins, "onSaveData");
}
@@ -684,17 +632,11 @@ export default class Controller {
if (!json.restartRequired) { // Added in 2.0.0.alpha.21
json.restartRequired = false;
}
if (json.systemStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.systemStartedAtMs = Date.now();
}
if (json.processStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.processStartedAtMs = Date.now();
}
return json;
});
}
static migrateHosts(rawJson: unknown[]): Static<typeof HostRecord.jsonSchema>[] {
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
let serialized = rawJson as any;
// New format 2.0.0.alpha.19
@@ -705,7 +647,7 @@ export default class Controller {
return serialized;
}
static finaliseHosts(host: HostRecord): HostRecord {
static finaliseHosts(host: HostInfo): HostInfo {
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
@@ -713,8 +655,8 @@ export default class Controller {
return host;
}
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceRecord.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceRecord.jsonSchema>[];
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
return serialized.map(json => {
if (!json.config) { // New format 2.0.0.alpha.14
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
@@ -723,7 +665,7 @@ export default class Controller {
});
}
static finaliseInstances(instance: InstanceRecord): InstanceRecord {
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
@@ -732,17 +674,6 @@ export default class Controller {
return instance;
}
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
}
return json;
});
}
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
return serialized.map(json => {
@@ -757,20 +688,6 @@ export default class Controller {
});
}
static finaliseUsers(user: UserRecord, users: Map<UserRecord["id"], UserRecord>): UserRecord {
const existingUser = users.get(user.id);
if (existingUser) {
// All lowercase ids in 2.0.0.alpha.19
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
// We assume the user with the lower online time is the duplicate
existingUser.merge(user);
return existingUser;
}
user.merge(existingUser);
}
return user;
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
let manifest = {};
try {
@@ -889,7 +806,7 @@ export default class Controller {
);
}
hostsUpdated(hosts: HostRecord[]) {
hostsUpdated(hosts: HostInfo[]) {
let updates = hosts.map(host => host.toHostDetails());
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
@@ -901,6 +818,75 @@ export default class Controller {
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
}
/**
* Get instance by ID for a request
*
* @param instanceId - ID of instance to get.
* @returns Info for the given instance if it exists
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId:number): InstanceInfo {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/**
* Create a new instance
*
* Adds common Factorio settings to the provided instance config then
* creates an instance using that config in the cluster.
*
* @example
* let instanceConfig = new lib.InstanceConfig("controller");
* instanceConfig.set("instance.name", "My instance");
* let instance = await controller.instanceAssign(instanceConfig);
* await controller.instanceAssign(instance.id, hostId);
*
* @param instanceConfig -
* Config to base newly created instance on.
* @returns The created instance
*/
async instanceCreate(instanceConfig: lib.InstanceConfig): Promise<InstanceInfo> {
let instanceId = instanceConfig.get("instance.id");
if (this.instances.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${this.config.get("controller.name")}`,
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
"max_upload_slots": 5,
"ignore_player_limit_for_returning_players": false,
"allow_commands": "admins-only",
"autosave_interval": 10,
"autosave_slots": 5,
"afk_autokick_interval": 0,
"auto_pause": false,
"only_admins_can_pause_the_game": true,
"autosave_only_on_server": true,
"non_blocking_saving": false,
...instanceConfig.get("factorio.settings"),
};
instanceConfig.set("factorio.settings", settings);
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
this.instances.set(instance);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
this.addInstanceHooks(instance);
return instance;
}
/**
* Removes all saves currently stored for the given instance, if any
* @param instanceId - Id of Instance to clear saves for.
@@ -910,6 +896,84 @@ export default class Controller {
this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId));
}
/**
* Change assigned host of an instance
*
* Unassigns instance from existing host if already assigned to one and
* then assigns it to the given host. This is the only supported way of
* changing the instance.assigned_host config entry.
*
* Note: this will not transfer any files or saves stored on the host
* the instance was previously assgined to the new one.
*
* @param instanceId - ID of Instance to assign.
* @param hostId - ID of host to assign instance to.
*/
async instanceAssign(instanceId: number, hostId?: number) {
let instance = this.getRequestInstance(instanceId);
// Check if target host is connected
let newHostConnection: HostConnection | undefined;
if (hostId !== undefined) {
newHostConnection = this.wsServer.hostConnections.get(hostId);
if (!newHostConnection) {
// The case of the host not getting the assign instance message
// still have to be handled, so it's not a requirement that the
// target host be connected to the controller while doing the
// assignment, but it is IMHO a better user experience if this
// is the case.
throw new lib.RequestError("Target host is not connected to the controller");
}
}
// Unassign from currently assigned host if it is connected.
let currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost !== null && hostId !== currentAssignedHost) {
let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId));
}
}
// Remove saves recorded from currently assigned host if any
this.clearSavesOfInstance(instanceId);
// Assign to target
instance.config.set("instance.assigned_host", hostId ?? null);
if (hostId !== undefined && newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host"))
);
} else {
instance.status = "unassigned";
}
this.instances.set(instance);
}
/**
* Delete an instance
*
* Permanently deletes the instance from its assigned host (if assigned)
* and the controller. This action cannot be undone.
*
* @param instanceId - ID of instance to delete.
*/
async instanceDelete(instanceId: number) {
let instance = this.getRequestInstance(instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId));
}
let prev = instance.status;
this.instances.delete(instance);
this.clearSavesOfInstance(instanceId);
this.userManager.clearStatsOfInstance(instanceId);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
}
/**
* Notify the config of an instance updated
*
@@ -919,7 +983,7 @@ export default class Controller {
* @param instance -
* Instance to nodify the config updated.
*/
async instanceConfigUpdated(instance: InstanceRecord) {
async instanceConfigUpdated(instance: InstanceInfo) {
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
let connection = this.wsServer.hostConnections.get(hostId);
@@ -931,7 +995,16 @@ export default class Controller {
}
}
instanceUpdated(instances: InstanceRecord[]) {
addInstanceHooks(instance: InstanceInfo) {
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
instance.updatedAtMs = Date.now();
this.instances.set(instance); // Trigger update logic
lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev);
});
}
instanceDetailsUpdated(instances: InstanceInfo[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
@@ -983,12 +1056,17 @@ export default class Controller {
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
}
usersUpdated(users: UserRecord[]) {
usersUpdated(users: ControllerUser[]) {
const now = Date.now();
for (const user of users) {
user.updatedAtMs = now;
}
this.userManager.dirty = true;
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
const users = [...this.users.records.values()].filter(
const users = [...this.userManager.users.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
return users.length ? new lib.UserUpdatesEvent(users) : null;
@@ -999,7 +1077,7 @@ export default class Controller {
* permissions for this user may have changed.
* @param user - User permisions updated for.
*/
userPermissionsUpdated(user: Readonly<User>) {
userPermissionsUpdated(user: ControllerUser) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
@@ -1050,7 +1128,7 @@ export default class Controller {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
continue;
}
@@ -1075,9 +1153,6 @@ export default class Controller {
this.plugins.set(pluginInfo.name, controllerPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -1261,7 +1336,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
const instance = this.instances.getForRequest(dst.id);
let instance = this.getRequestInstance(dst.id);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1296,7 +1371,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
const instance = this.instances.get(dst.id);
let instance = this.instances.get(dst.id);
if (!instance) {
throw new Error(`Instance with ID ${dst.id} does not exist`);
}
@@ -1355,7 +1430,7 @@ export default class Controller {
}
async sendRequestToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>) {
let instance = this.instances.getForRequest(request.instanceId);
let instance = this.getRequestInstance(request.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1368,7 +1443,7 @@ export default class Controller {
}
sendEventToHostByInstanceId<T extends { instanceId: number }>(event: T & lib.Event<T>) {
let instance = this.instances.getForRequest(event.instanceId);
let instance = this.getRequestInstance(event.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
return;
+96
View File
@@ -0,0 +1,96 @@
import { Static, Type } from "@sinclair/typebox";
import {
IControllerUser, PermissionError, PlayerStats,
Role, SubscribableDatastore, User, permissions,
} from "@clusterio/lib";
export default class ControllerUser extends User implements IControllerUser {
constructor(
private _controllerRoles: SubscribableDatastore<Role>,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
controllerRoles,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+50 -43
View File
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
/**
@@ -16,7 +16,7 @@ import HostRecord from "./HostRecord";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostRecord;
private info: HostInfo;
plugins: Map<string, string>;
constructor(
@@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostRecord(
this.info = new HostInfo(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
@@ -47,7 +47,7 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
@@ -57,7 +57,7 @@ export default class HostConnection extends BaseConnection {
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
const instances: InstanceRecord[] = [];
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
@@ -68,7 +68,7 @@ export default class HostConnection extends BaseConnection {
instance.status = "unknown";
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instances.records.setMany(instances);
this._controller.instances.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
@@ -207,14 +207,9 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
// If the instance has transitioned to running then updated the started at time
if (request.status === "running" && prev === "starting") {
instance.startedAtMs = Date.now();
}
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
this._controller.instances.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -230,7 +225,7 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceRecord[] = [];
const instanceUpdates: InstanceInfo[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
@@ -251,9 +246,6 @@ export default class HostConnection extends BaseConnection {
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.factorioVersion = instanceData.factorioVersion;
if (instanceData.status === "running" && prev === "starting") {
controllerInstance.startedAtMs = Date.now();
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
@@ -264,30 +256,31 @@ export default class HostConnection extends BaseConnection {
}
instanceConfig.set("instance.assigned_host", this.id);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
instanceUpdates.push(newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
this._controller.instances.setMany(instanceUpdates);
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -336,18 +329,21 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
if (event.type === "join") {
user.notifyJoin(instanceId);
this._controller.userManager.notifyJoin(user, instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
this._controller.userManager.notifyLeave(user, instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
user.saveRecord();
this._controller.usersUpdated([user]);
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
@@ -363,9 +359,13 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isAdmin = event.admin;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
@@ -379,11 +379,14 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isBanned = event.banned;
user.banReason = event.reason;
user.saveRecord();
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
@@ -398,9 +401,13 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isWhitelisted = event.whitelisted;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
@@ -1,8 +1,10 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/** Underlying data class for hosts on the controller */
export default class HostRecord {
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
constructor(
/** Id of this host */
public id: number,
@@ -31,12 +33,12 @@ export default class HostRecord {
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"connected": Type.Optional(Type.Boolean()),
"remote_address": Type.Optional(Type.String()),
"public_address": Type.Optional(Type.String()),
"token_valid_after": Type.Optional(Type.Number()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
"connected": Type.Boolean(),
"remote_address": Type.String(),
"public_address": Type.String(),
"token_valid_after": Type.Number(),
"updated_at_ms": Type.Number(),
"is_deleted": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -54,50 +56,19 @@ export default class HostRecord {
);
}
toJSON() {
const json = {
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
} as Static<typeof HostRecord.jsonSchema>;
if (this.connected !== false) {
json.connected = this.connected;
}
if (this.remoteAddress !== "") {
json.remote_address = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.public_address = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.token_valid_after = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.is_deleted = this.isDeleted;
}
return json;
}
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
return new HostRecord(
details.id,
details.name,
details.version,
plugins,
details.connected,
details.remoteAddress,
details.publicAddress,
details.tokenValidAfter,
details.updatedAtMs,
details.isDeleted,
);
connected: this.connected,
remote_address: this.remoteAddress,
public_address: this.publicAddress,
token_valid_after: this.tokenValidAfter,
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
}
toHostDetails() {
@@ -114,3 +85,4 @@ export default class HostRecord {
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
json.updatedAtMs,
);
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.updatedAtMs,
);
}
get isDeleted() {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
}
}
-274
View File
@@ -1,274 +0,0 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
tags: ["clusterio"],
max_players: 0,
visibility: { public: true, lan: true },
game_password: "",
require_user_verification: true,
max_upload_in_kilobytes_per_second: 0,
max_upload_slots: 5,
ignore_player_limit_for_returning_players: false,
allow_commands: "admins-only",
autosave_interval: 10,
autosave_slots: 5,
afk_autokick_interval: 0,
auto_pause: false,
only_admins_can_pause_the_game: true,
autosave_only_on_server: true,
non_blocking_saving: false,
} as const;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
) {
for (const instance of records.values()) {
this.addInstanceHooks(instance);
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: InstanceRecord["id"]): boolean {
return this.records.has(id);
}
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
return this.records.get(id);
}
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
return this.getMutable(id);
}
valuesMutable(): IterableIterator<InstanceRecord> {
return this.records.values();
}
values(): IterableIterator<Readonly<InstanceRecord>> {
return this.valuesMutable();
}
/**
* Get instance by ID for a request.
*
* Convenience wrapper around {@link getMutable} that throws
* if the instance does not exist.
*
* @param instanceId - ID of instance to get.
* @returns The instance record.
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getForRequest(instanceId: number): InstanceRecord {
const instance = this.records.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/* ------------------------------------------------------------------------- */
/* Internal Helpers */
/* ------------------------------------------------------------------------- */
private async _notifyStatusChanged(
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
}
private async _notifyConfigFieldChanged(
instance: InstanceRecord,
field: string,
curr: unknown,
prev: unknown,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
}
/**
* Attach change listeners to an instance record.
*
* Hooks into configuration field changes and ensures the datastore
* is updated and plugins are notified when a config field changes.
*
* @param instance - The instance record to attach hooks to.
* @internal
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
});
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Create a new instance.
*
* @example
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
* const instance = await controller.instances.createInstance(config);
*
* @param instanceConfig - Config to base the newly created instance on.
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
* @returns The created instance record.
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
*/
async createInstance(
instanceConfig: lib.InstanceConfig,
_suppressChanges?: boolean,
): Promise<InstanceRecord> {
const instanceId = instanceConfig.get("instance.id");
if (this.records.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
if (!_suppressChanges) {
this.records.set(instance);
await this._notifyStatusChanged(instance);
}
return instance;
}
/**
* Change the assigned host of an instance.
*
* Unassigns the instance from the currently assigned host (if connected)
* and assigns it to the provided host.
*
* This is the only supported way of modifying the
* `instance.assigned_host` configuration entry.
*
* Note:
* - This does NOT transfer any files or saves between hosts.
* - If the instance is already assigned to the specified host, no action is taken.
*
* @param instanceId - ID of instance to assign.
* @param hostId - ID of host to assign the instance to. If undefined,
* the instance will be unassigned.
*
* @throws {module:lib.RequestError} if the instance does not exist.
* @throws {module:lib.RequestError} if the target host is not connected.
*/
async assignInstance(
instanceId: number,
hostId: number | null,
): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostConnections = this._controller.wsServer.hostConnections;
const currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost === hostId) {
return;
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
newHostConnection = hostConnections.get(hostId);
if (!newHostConnection) {
throw new lib.RequestError("Target host is not connected to the controller");
}
}
if (currentAssignedHost !== null) {
const oldHostConnection = hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(
new lib.InstanceUnassignInternalRequest(instanceId),
);
}
}
this._controller.clearSavesOfInstance(instanceId);
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} else {
instance.status = "unassigned";
}
this.records.set(instance);
}
/**
* Unassign an instance from its current host.
*
* Convenience wrapper around {@link assignInstance} that removes
* the assigned host.
*
* @param instanceId - ID of instance to unassign.
*/
async unassignInstance(instanceId: number): Promise<void> {
return this.assignInstance(instanceId, null);
}
/**
* Delete an instance permanently.
*
* Sends a delete request to the assigned host (if any),
* removes the instance from the datastore,
* clears associated saves and user statistics.
*
* @param instanceId - ID of instance to delete.
*
* @throws {module:lib.RequestError} if the instance does not exist.
*/
async deleteInstance(instanceId: number): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this._controller.sendTo(
{ hostId },
new lib.InstanceDeleteInternalRequest(instanceId),
);
}
const prevStatus = instance.status;
this.records.delete(instance);
this._controller.clearSavesOfInstance(instanceId);
this._controller.users.clearStatsOfInstance(instanceId);
await this._notifyStatusChanged(instance, prevStatus);
}
}
-99
View File
@@ -1,99 +0,0 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: lib.TargetVersion,
public startedAtMs = 0,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
get id(): number {
return this.config.get("instance.id");
}
get isDeleted() {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
);
}
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
const json: any = {
config: this.config.toJSON(),
status: this.status,
};
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
return json;
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
}
}
-146
View File
@@ -1,146 +0,0 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord";
import { Static } from "@sinclair/typebox";
/**
* Represents a user as viewed from the controller.
*
* All methods are safe to use when readonly.
* Only use mutable when needing to change multiple values.
*/
export default class User extends UserRecord implements IUser {
constructor(
private _controllerUsers: SubscribableDatastore<UserRecord>,
private _controllerRoles: SubscribableDatastore<Role>,
...args: ConstructorParameters<typeof UserRecord>
) {
super(...args);
}
static fromJSON(
json: Static<typeof this.jsonSchema>,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
) {
return this.fromUserRecord(
UserRecord.fromJSON(json),
_controllerUsers,
_controllerRoles,
);
}
static fromUserRecord(
userRecord: UserRecord,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
): User {
return new this(
_controllerUsers,
_controllerRoles,
userRecord.tokenValidAfter,
userRecord.name,
userRecord.roleIds,
userRecord.instances,
userRecord.isAdmin,
userRecord.isBanned,
userRecord.isWhitelisted,
userRecord.banReason,
userRecord.updatedAtMs,
userRecord.isDeleted,
userRecord.instanceStats,
);
}
/** Roles this user has */
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/** Save the underlying user record */
saveRecord() {
this._controllerUsers.set(this);
}
/** Set a property on the underlying record, then save it. */
set<K extends keyof this>(key: K, value: this[K]) {
this[key] = value;
this.saveRecord();
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
this.saveRecord();
}
/** Add a role to this user */
addRole(roleId: number) {
const role = this._controllerRoles.get(roleId);
if (!role) {
throw new Error(`role ${roleId} does not exist`);
}
this.roleIds.add(roleId);
this.saveRecord();
}
/** Remove a role from user */
removeRole(roleId: number) {
if (!this.roleIds.delete(roleId)) {
return false;
}
this.saveRecord();
return true;
}
/** Set a user as being online on a specific instance */
notifyJoin(instanceId: number) {
this.instances.add(instanceId);
this.saveRecord();
}
/** Set a user as being offline on a specific instance */
notifyLeave(instanceId: number) {
this.instances.delete(instanceId);
this.saveRecord();
}
/** Clear the stats of a player on a specific instance */
clearInstanceStats(instanceId: number) {
this.instances.delete(instanceId);
this.instanceStats.delete(instanceId);
this.recalculatePlayerStats();
this.saveRecord();
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (const roleId of this.roleIds) {
const role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+76 -106
View File
@@ -3,45 +3,65 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
/**
* Access layer for users on the controller.
*
* In performance critical paths the raw records can be accessed directly.
* Otherwise this class will wrap data in a view class with a set and save method.
*
* Manages users and roles
* @alias module:controller/src/UserManager
*/
export default class UserManager {
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
) {
}
/**
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
* This migration separates them so they can be loaded by datastore.
*
* @param usersFilePath The src and dst file path for the users json file
* @param rolesFilePath The dst for the roles json file
*/
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
try {
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
await this._roles.save(); // Force a save to prevent data loss
content = content.users;
}
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
let duplicates = 0;
for (const serializedUser of content) {
const user = ControllerUser.fromJSON(serializedUser, this._roles);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
duplicates += 1;
// We assume the user with the lower online time is the duplicate
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
existingUser.merge(user);
continue; // Skip users.set
}
user.merge(existingUser);
}
this.users.set(user.id, user);
}
if (duplicates) {
const backupPath = `${filePath}.${Date.now()}.bak`;
lib.logger.warn(
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
);
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
@@ -49,116 +69,66 @@ export default class UserManager {
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: UserRecord["id"]) {
return this.records.has(id);
}
getMutable(id: UserRecord["id"]) {
const userRecord = this.records.getMutable(id);
if (!userRecord) {
return undefined;
}
return User.fromUserRecord(
userRecord,
this.records,
this._controllerRoles,
async save(filePath: string): Promise<void> {
this.dirty = false;
await lib.safeOutputFile(filePath,
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
);
}
get(id: UserRecord["id"]): Readonly<User> | undefined {
return this.getMutable(id);
}
hasName(name: UserRecord["name"]) {
return this.has(name.toLowerCase());
}
getByNameMutable(name: UserRecord["name"]) {
return this.getMutable(name.toLowerCase());
}
getByName(name: UserRecord["name"]) {
return this.get(name.toLowerCase());
}
* valuesMutable(): IterableIterator<User> {
const roles = this._controllerRoles;
const users = this.records;
for (const record of users.values()) {
yield User.fromUserRecord(record, users, roles);
}
}
values(): IterableIterator<Readonly<User>> {
return this.valuesMutable();
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Creates a new user and add it to the user database.
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name: string): User {
createUser(name: string): ControllerUser {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
return user;
}
/** Get or create a user with a given name. */
getOrCreateUser(name: string): User {
return this.getByNameMutable(name) ?? this.createUser(name);
}
/**
* Deletes an existing user
*
* @param user - user to delete
*/
deleteUser(user: UserRecord) {
return this.records.delete(user);
}
/* ------------------------------------------------------------------------- */
/* Helpers */
/* ------------------------------------------------------------------------- */
/**
* Sign access token for the given user
*
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(user: UserRecord): string {
signUserToken(user: ControllerUser): string {
return jwt.sign(
{ aud: "user", user: user.id },
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
}
this.dirty = true;
}
}
-102
View File
@@ -1,102 +0,0 @@
import { UserDetails } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/** Underlying data class for users on the controller */
export default class UserRecord extends UserDetails {
constructor(
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof UserDetails>
) {
super(...args);
}
static jsonSchema = Type.Object({
token_valid_after: Type.Optional(Type.Number()),
...UserDetails.jsonSchema.properties,
});
// _a and _b are a ts workaround for allowing User to have extra arguments
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
return this.fromUserDetails(
UserDetails.fromJSON(json),
json.token_valid_after,
);
}
toJSON() {
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
if (this.tokenValidAfter > 0) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
static fromUserDetails(
userDetails: UserDetails,
tokenValidAfter = 0,
) {
return new this(
tokenValidAfter,
userDetails.name,
userDetails.roleIds,
userDetails.instances,
userDetails.isAdmin,
userDetails.isBanned,
userDetails.isWhitelisted,
userDetails.banReason,
userDetails.updatedAtMs,
userDetails.isDeleted,
userDetails.instanceStats,
);
}
toUserDetails() {
return new UserDetails(
this.name,
this.roleIds,
this.instances,
this.isAdmin,
this.isBanned,
this.isWhitelisted,
this.banReason,
this.updatedAtMs,
this.isDeleted,
this.instanceStats,
);
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: UserDetails) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
// Player stats must be recalculated after modifying instance stats
this.recalculatePlayerStats();
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ ${err.stack}`
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.users.getByNameMutable(tokenPayload.user);
user = this.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
+7 -13
View File
@@ -109,7 +109,9 @@ function getPlugins(req: Request, res: Response) {
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
let enabled = loaded && req.app.locals.controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
// 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.
@@ -134,12 +136,6 @@ function getPlugins(req: Request, res: Response) {
res.send(plugins);
}
function getClusterName(req: Request, res: Response) {
const controller: Controller = req.app.locals.controller;
const clusterName = controller.config.get("controller.name");
res.json({ name: clusterName });
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
@@ -189,7 +185,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -240,9 +236,9 @@ async function uploadExport(req: Request, res: Response) {
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
@@ -263,7 +259,7 @@ async function uploadExport(req: Request, res: Response) {
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacks.set(modPack);
@@ -668,7 +664,6 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
@@ -701,6 +696,5 @@ export const webRoutes = [
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+1 -1
View File
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.tsx",
devServer: {
+28 -132
View File
@@ -43,9 +43,6 @@ 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",
@@ -56,10 +53,6 @@ const factorioLocations = {
],
};
function supportsHeadlessDownload(platform = process.platform) {
return headlessDownloadPlatforms.has(platform);
}
class LineSplitter extends stream.Transform {
constructor(options) {
@@ -121,7 +114,7 @@ async function execFile(cmd, args) {
escaped,
{
shell: true,
// eslint-disable-next-line node/no-process-env
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
},
(err, stdout, stderr) => {
@@ -342,7 +335,7 @@ async function migrateRename(args) {
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode, "");
await writeScripts(mode);
}
logger.info(
@@ -403,7 +396,7 @@ async function installClusterio(mode, plugins) {
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
// eslint-disable-next-line node/global-require
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
pluginList.set(pluginInfo.name, plugin);
}
@@ -427,7 +420,7 @@ async function groupIdToName(gid) {
}
}
async function writeScripts(mode, selfSignedCert) {
async function writeScripts(mode) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
@@ -481,13 +474,12 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
${extraCaCertsLine}:restart
:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
@@ -495,20 +487,18 @@ goto restart
`
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
${extraCaCertsLine}while true; do
while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
"systemd/clusteriohost.service",
`\
@@ -522,7 +512,7 @@ WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -654,14 +644,14 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (supportsHeadlessDownload()) {
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "Automatically download latest headless Factorio release?",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
@@ -678,7 +668,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", value: "factorio" },
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
@@ -699,7 +689,6 @@ async function inquirerMissingArgs(args) {
}
if (["standalone", "controller", "host"].includes(answers.mode)) {
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
answers = await inquirer.prompt([
{
type: "confirm",
@@ -764,8 +753,8 @@ async function inquirerMissingArgs(args) {
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
@@ -782,7 +771,10 @@ async function downloadLinuxServer() {
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.emptyDir(tmpDir);
await fs.ensureDir(tmpDir);
// follow the redirect
res = await fetch(url.href);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
@@ -801,92 +793,9 @@ async function downloadLinuxServer() {
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() {
@@ -911,10 +820,6 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/host]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -930,9 +835,6 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("self-signed-cert", {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
@@ -949,17 +851,17 @@ async function main() {
;
if (process.platform === "linux") {
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",
});
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
args = args.argv;
@@ -989,13 +891,7 @@ async function main() {
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");
}
await downloadLinuxServer();
}
if (!dev) {
@@ -1047,7 +943,7 @@ async function main() {
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode, args.selfSignedCert);
await writeScripts(answers.mode);
}
if (answers.mode === "ctl") {
+1 -1
View File
@@ -50,7 +50,7 @@ function escapeArg(arg) {
// For all other platforms the command is /bin/sh -c <command arg arg ...>
// The command and its arguments are passed as a single argument to the shell.
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
// eslint-disable-next-line no-lonely-if
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
return arg;
}
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.21",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,11 +13,11 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"chalk": "catalog:",
"fs-extra": "catalog:",
"chalk": "^4.1.2",
"fs-extra": "^11.3.0",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+9 -17
View File
@@ -3,8 +3,6 @@ const { logger } = require("./logging");
const fs = require("fs-extra");
const path = require("path");
const _package = require("./package.json");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -94,8 +92,8 @@ async function copyPluginTemplates(pluginName, templates) {
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
@@ -104,7 +102,6 @@ async function copyPluginTemplates(pluginName, templates) {
prepare.push("tsc --build");
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
if (webpack) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
@@ -112,7 +109,7 @@ async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
prepare.push("webpack-cli --env production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
@@ -142,6 +139,7 @@ async function copyPluginTemplates(pluginName, templates) {
// Files for lua modules
if (templates.includes("module")) {
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
files.set("module/module.json", path.join(commonPath, "module/module.json"));
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
@@ -149,6 +147,7 @@ async function copyPluginTemplates(pluginName, templates) {
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
} else {
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
pluginContexts += 1;
}
}
@@ -165,25 +164,18 @@ async function copyPluginTemplates(pluginName, templates) {
// Properties that will control the replacements in the templates
const properties = {
// Weather this is ts or js
config: config,
typescript: !javascriptOnly,
ext: ext,
// Which templates where requested
multi_context: pluginContexts > 1,
controller: templates.includes("controller"),
host: templates.includes("host"),
instance: templates.includes("instance"),
module: templates.includes("module"),
ctl: templates.includes("ctl"),
web: templates.includes("web"),
// Macro flags for context requirements
webpack: webpack,
config: config,
// String values for package json
clusterio_version: _package.version,
node_version: _package.engines.node,
prepare: prepare.join(" && "),
multi_context: pluginContexts > 1,
plugin_name: pluginName,
prepare: prepare.join(" && "),
ext: ext,
};
// Write all the template files
@@ -1,2 +1,3 @@
node_modules/
dist/
node_modules/
package-lock.json
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,3 @@
webpack.config.js
dist/web/build
dist/browser
dist/web
@@ -1,14 +1,3 @@
--[[
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
Most of your code will be within this one file, but you can create more and require them
clusterio_api exposes functions to enable communication with your InstancePlugin
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
]]
local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
@@ -17,12 +6,10 @@ local compat = require("modules/clusterio/compat")
local MyModule = {
}
--[[
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
There are edge cases in the following events: pre player left, player left, console command, and config changed
If you use any of these events and access `script_data` then make sure to call this function first
]]
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
-- If you use any of these events and access globalData then make sure to call this function first
local script_data = {}
local function setup_script_data()
if compat.script_data["__plugin_name__"] == nil then
@@ -41,13 +28,12 @@ end
--- Public methods should be available though your top level module table
function MyModule.foo()
game.print("foo: " .. (script_data.counter or 0))
game.print("foo")
end
--- Private methods should be local to the file, this will prevent others from calling it
local function bar()
script_data.counter = (script_data.counter or 0) + 1
game.print("bar: " .. script_data.counter)
game.print("bar")
end
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
@@ -75,7 +61,7 @@ local function on_nth_tick_300()
bar()
end
--- Factorio events are accessible through defines.events, you can have one handler per event per file
--- Factorio events are accessible through defines.events, you can have one handler per event per module
local e = defines.events
local events = {
[clusterio_api.events.on_server_startup] = on_server_startup,
@@ -87,7 +73,7 @@ local on_nth_tick = {
[300] = on_nth_tick_300,
}
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
--- Always return the top level module table from control, this is how clusterio will access your event handlers
MyModule.events = events --- @package
MyModule.on_nth_tick = on_nth_tick --- @package
return MyModule
@@ -1,9 +1,3 @@
--[[
It is best practice to not expose any globals because all modules share a global environment
However, sometimes you need globals, for example to access functions within rcon commands
Therefore, we advise that this should be the only file in your module to expose globals
Typically this would be your control file as shown in the example below
]]
-- Access using `/sc __plugin_name__.foo()`
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
-- luacheck: globals __plugin_name__
__plugin_name__ = require("modules/__plugin_name__/control")
@@ -1,8 +1,4 @@
--[[
It is typical for a module to have a control file as the entry point, although this is not a requirement
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
You are not required to specify any default export, but note that a module can still require yours directly
]]
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
-- Access the exports from other modules using require("modules/__plugin_name__")
return require("modules/__plugin_name__/control")
+5 -28
View File
@@ -12,22 +12,10 @@
"prepare": "__prepare__"
},
"engines": {
"node": "__node_version__"
"node": ">=18"
},
"peerDependencies": {
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
"@clusterio/lib": "workspace:*"
},
"devDependencies": {
//%// Typescript and type declarations
@@ -44,24 +32,13 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if webpack
//%if web | controller // The controller uses webpack even when there is no plugin code
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
"@clusterio/web_ui": "workspace:*",
//%endif
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.__ext__x",
output: {
@@ -18,7 +18,9 @@ module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
exposes: {
"./": "./index.__ext__",
"./package.json": "./package.json",
//%if web
"./web": "./web/index.__ext__x",
//%endif
},
shared: {
"@clusterio/lib": { import: false },
@@ -1,7 +1,7 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
const {
PluginExampleEvent, PluginExampleRequest,
@@ -9,7 +9,7 @@ const {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
} = require("./messages");
//%endif
@@ -26,8 +26,7 @@ class ControllerPlugin extends BaseControllerPlugin {
//%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)]]);
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
@@ -71,7 +70,6 @@ class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
+1 -5
View File
@@ -7,14 +7,12 @@ 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
@@ -23,7 +21,6 @@ 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
@@ -32,7 +29,6 @@ lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
@@ -44,7 +40,7 @@ const plugin = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -24,26 +24,18 @@ class InstancePlugin extends BaseInstancePlugin {
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
@@ -12,7 +12,7 @@ import React, {
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -20,7 +20,7 @@ import {
//%if controller // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
@@ -1,6 +1,6 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -8,7 +8,7 @@ import {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
} from "./messages";
//%endif
@@ -25,8 +25,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
@@ -37,7 +36,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
//%if instance
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
@@ -50,7 +49,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
@@ -70,7 +69,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request: lib.SubscriptionRequest) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
@@ -6,14 +6,12 @@ lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
@@ -22,7 +20,6 @@ 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
@@ -31,7 +28,6 @@ lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
@@ -31,25 +31,18 @@ export class InstancePlugin extends BaseInstancePlugin {
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
},
}
@@ -1,13 +1,8 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"target": "es2022",
"jsx": "react-jsx",
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"extends": "../../tsconfig.browser.json",
"references": [
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -1,13 +1,8 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"outDir": "${configDir}/dist/node"
},
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
@@ -12,7 +12,7 @@ import React, {
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -20,7 +20,7 @@ import {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.example.permission.view",
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
+25 -31
View File
@@ -5,7 +5,7 @@
* @module ctl/ctl
*/
import fs from "fs-extra";
import yargs, { type Argv } from "yargs";
import yargs from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
@@ -27,8 +27,8 @@ import BaseCtlPlugin from "./src/BaseCtlPlugin";
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, token: string) {
super(url, maxReconnectDelay);
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
@@ -54,6 +54,8 @@ class ControlConnector extends lib.WebSocketClientConnector {
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Certificate authority used to validate TLS connections to the controller. */
tlsCa?: string;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
@@ -62,10 +64,12 @@ export class Control extends lib.Link {
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
tlsCa: string | undefined,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = controlConfig;
this.tlsCa = tlsCa;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
@@ -152,17 +156,7 @@ export async function initialize(
ctlPlugins?: Map<string, BaseCtlPlugin>,
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
// If the currently loaded yargs object supports .reset() we use that (older versions),
// otherwise we fall back to creating a brand-new parser instance by invoking the
// yargs factory function directly with an empty argv array. This avoids Mocha’s
// own CLI flags (or anything else that touched yargs earlier in the same process)
// from leaking into subsequent parses inside the test runner.
const parser: Argv = typeof (yargs as any).reset === "function"
? (yargs as any).reset()
: (yargs as any)([]);
parser
yargs
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -185,16 +179,15 @@ export async function initialize(
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
.wrap(yargs.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
// eslint-disable-next-line node/no-sync
let args = parser.parseSync(argv) as CtlArguments;
let args = yargs.parseSync(argv) as CtlArguments;
// Log stream for the ctl session. Skipped in testing.
if (!noLoggerTransport) {
@@ -224,21 +217,15 @@ export async function initialize(
}
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
args = parser
const rootCommands = await commands.registerCommands(ctlPlugins, yargs);
args = yargs
.help()
.strict()
.parse(argv) as CtlArguments
;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
const controlConfigPath = args.config;
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
@@ -254,13 +241,13 @@ export async function initialize(
}
if (args._.length === 0) {
parser.showHelp();
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
yargs.showHelp();
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
await lib.handleConfigCommand(args, controlConfig);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
@@ -295,13 +282,20 @@ async function startControl() {
return;
}
let tlsCa: string | undefined;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
@@ -316,7 +310,7 @@ async function startControl() {
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
+10 -9
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,24 +17,25 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"as-table": "^1.0.55",
"fs-extra": "^11.3.0",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
"typescript": "^5.7.3"
}
}
-4
View File
@@ -1,4 +0,0 @@
export function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
File diff suppressed because it is too large. Load diff
-201
View File
@@ -1,201 +0,0 @@
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 { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
controllerCommands.add(new lib.Command({
definition: ["stop", "Stop controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerStopRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
},
}));
const controllerConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "controller config management",
});
controllerConfigCommands.add(new lib.Command({
definition: ["list", "List controller configuration"],
handler: async function(args: object, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set <field> [value]", "Set field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set-prop <field> <prop> [value]", "Set property of field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["edit [editor]", "Edit controller configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { editor: string }, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(
config, lib.ControllerConfig, {}
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
controllerCommands.add(controllerConfigCommands);
const controllerPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "controller plugin inspection",
});
controllerPluginCommands.add(new lib.Command({
definition: ["list", "List plugins on controller"],
handler: async function(args: object, control: Control) {
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
controllerCommands.add(controllerPluginCommands);
-11
View File
@@ -1,11 +0,0 @@
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
handler: async function(args: object, control: Control) {
await control.send(new lib.DebugDumpWsRequest());
control.keepOpen = true;
},
}));
-305
View File
@@ -1,305 +0,0 @@
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 { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, control: Control) {
let hosts = await control.send(new lib.HostListRequest());
print(asTable(hosts));
},
}));
hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, control: Control) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
hostCommands.add(new lib.Command({
definition: ["create-config", "Create host config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
});
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
control: Control
) {
let config = await control.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
let content = JSON.stringify(config, null, "\t");
if (args.output === "-") {
print(content);
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostUpdateRequest());
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const hostPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "host plugin inspection",
});
hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
}))));
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to update on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to install on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
hostCommands.add(hostPluginCommands);
-685
View File
@@ -1,685 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import os from "os";
import child_process from "child_process";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
instanceCommands.add(new lib.Command({
definition: [["list", "l"], "List instances known to the controller"],
handler: async function(args: object, control: Control) {
let list = await control.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
},
}));
instanceCommands.add(new lib.Command({
definition: ["create <name>", "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
const instanceConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Instance config management",
});
instanceConfigCommands.add(new lib.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// If this is from stdin or looks like an array, object or string
// literal throw the parse error, otherwise assume this is a string.
// The resoning behind this is that correctly quoting the string
// with the all the layers of quote removal at play is difficult.
// See the following table for how to pass "That's a \" quote" in
// different environments:
// ----------------------- WARNING -------------------------------
// This table is most likely invalid due a change in yargs parsing
// ---------------------------------------------------------------
// cmd : """""That's a \\"" quote"""""
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
// PowerShell : '"""""That''s a \\"" quote"""""'
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
// bash : '""That'\''s a \" quote""'
// bash + npx : '""That'\''s a \" quote""'
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { instance: string, editor: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new lib.Command({
definition: ["assign <instance> [host]", "Assign instance to a host", (yargs) => {
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
}],
handler: async function(args: { instance: string, host?: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined;
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
},
}));
async function loadMapSettings(
args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string},
) {
let seed = args.seed;
let mapGenSettings;
let mapSettings;
if (args.mapExchangeString) {
let parsed = lib.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString());
}
if (args.mapSettings) {
mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString());
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
const instanceSaveCommands = new lib.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
instanceSaveCommands.add(new lib.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
)));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(
args: {
instance: string,
name: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
},
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args: { instance: string, oldName: string, newName: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args: { instance: string, source: string, destination: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Save name must end with .zip");
}
let instanceId = await lib.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
const response = await fetch(url, {
method: "POST",
body: await fs.createReadStream(args.filepath),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] };
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.saves && result.saves.length) {
logger.info(`Successfully uploaded as ${result.saves[0]}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading save failed");
}
},
}));
instanceSaveCommands.add(new lib.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(
args: {
sourceInstance: string,
sourceSave: string,
targetInstance: string,
targetSave: string,
copy: boolean,
},
control: Control
) {
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
targetInstanceId,
args.targetSave || args.sourceSave,
args.copy
)
);
print(`Transferred as ${storedName} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
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}`}`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
},
}));
instanceCommands.add(instanceSaveCommands);
instanceCommands.add(new lib.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["restart <instance>", "Restart instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
yargs.options({
"force": {
describe: "Start instances even if they have exclude_from_start_all set",
nargs: 0,
type: "boolean",
default: false,
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
// Only start instances that are currently stopped
if (instance.status !== "stopped") {
return false;
}
// Respect exclude_from_start_all setting unless --force is used
if (!args.force && instance.excludeFromStartAll) {
return false;
}
return true;
});
if (instancesToStart.length === 0) {
print("No instances to start.");
return;
}
print(`Starting ${instancesToStart.length} instance(s):`);
for (const instance of instancesToStart) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
}
});
await Promise.all(startPromises);
print("Start all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
instance: string,
scenario: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
keepOpen: boolean,
},
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStopRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
if (instancesToStop.length === 0) {
print("No instances to stop.");
return;
}
print(`Stopping ${instancesToStop.length} instance(s):`);
for (const instance of instancesToStop) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
}
});
await Promise.all(stopPromises);
print("Stop all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceKillRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteRequest(instanceId));
},
}));
instanceCommands.add(new lib.Command({
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args: { instance: string, command: string }, control: Control) {
let result = await control.sendTo(
{ instanceId: await lib.resolveInstance(control, args.instance) },
new lib.InstanceSendRconRequest(args.command),
);
// Factorio includes a newline in its response output.
process.stdout.write(result);
},
}));
-90
View File
@@ -1,90 +0,0 @@
import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Control } from "../ctl";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
yargs.options({
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
});
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
control.keepOpen = true;
},
}));
logCommands.add(new lib.Command({
definition: ["query", "Query cluster log", (yargs) => {
yargs.options({
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Query log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: undefined },
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
all: boolean,
controller: boolean,
host: string | null,
instance: string | null,
maxLevel?: string,
limit: number,
start: boolean,
},
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
let result = await control.send(new lib.LogQueryRequest(
args.all,
args.controller,
hostIds,
instanceIds,
args.maxLevel as keyof typeof levels,
args.limit,
args.start ? "asc" : "desc",
));
if (!args.start) {
result.log.reverse();
}
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
format: new lib.TerminalFormat({ showTimestamp: true }),
transports: [
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
],
});
for (let info of result.log) {
stdoutLogger.log(info as any);
}
},
}));
-237
View File
@@ -1,237 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new lib.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
for (let entry of value) {
print(` ${entry}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modCommands.add(new lib.Command({
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
yargs.options({
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
print(asTable(mods));
},
}));
modCommands.add(new lib.Command({
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
yargs.options({
"page": {
describe: "Result page to show",
type: "number",
default: 1,
},
"page-size": {
describe: "Results per page to show",
type: "number",
default: 10,
},
"sort": {
describe: "sort results by given field",
type: "string",
},
"sort-order": {
describe: "order to sort results in (asc/desc)",
type: "string",
default: "asc",
},
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(
args: {
factorioVersion: string,
query: string,
page: number,
pageSize: number,
sort?: string,
sortOrder: "asc" | "desc",
fields: string[],
},
control: Control
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
args.sortOrder
));
let results = response.results.flatMap(result => result.versions);
if (!args.fields.includes("all")) {
for (let entry of results) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
for (let issue of response.queryIssues) {
print(issue);
}
print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`);
print(asTable(results));
},
}));
modCommands.add(new lib.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args: { file: string }, control: Control) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Mod filename must end with .zip");
}
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let response = await fetch(url, {
method: "POST",
body: fs.createReadStream(args.file),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: [], request_errors?: [], mods: Parameters<typeof lib.ModInfo.fromJSON>[0][]};
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.mods && result.mods.length) {
const mod = lib.ModInfo.fromJSON(result.mods[0]);
logger.info(`Successfully uploaded ${mod.filename}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading mod failed");
}
},
}));
modCommands.add(new lib.Command({
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let 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`;
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}`);
},
}));
modCommands.add(new lib.Command({
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
-315
View File
@@ -1,315 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" });
modPackCommands.add(new lib.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
let mods = new Map(
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
if (field === "mods") {
print(`${field}:`);
for (let [name, entry] of value) {
const mod = mods.get(`${name}_${entry.version}`);
const missing = !mod && "? ";
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
const warn = missing || badChecksum || "";
const enabled = entry.enabled ? "" : "(disabled) ";
print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of settings as any) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else if (field === "exportManifest") {
print(`${field}:`);
if (value && value.assets) {
print(" assets:");
for (let [name, fileName] of Object.entries(value.assets)) {
print(` ${name}: ${fileName}`);
}
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modPackCommands.add(new lib.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args: object, control: Control) {
let modPacks = await control.send(new lib.ModPackListRequest());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
print(asTable(modPacks));
},
}));
function setModPackSettings(
modPack: lib.ModPack,
args: {
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
) {
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
modPack.settings[checkedScope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new lib.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new lib.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
doSettings(args.colorSetting || [], value => {
let color = JSON.parse(value);
if (typeof color !== "object" || color === null) {
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
}
return color;
});
}
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new lib.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, enabled: true, version, sha1 });
}
}
function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) {
for (let mod of mods || []) {
if (!modPack.mods.has(mod)) {
throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`);
}
modPack.mods.get(mod)!.enabled = enabled;
}
}
modPackCommands.add(new lib.Command({
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(
args: {
name: string,
factorioVersion: string,
description?: string,
mods?: string[],
disabledMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args: { string: string }, control: Control) {
const modPack = lib.ModPack.fromModPackString(args.string);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
print(modPack.toModPackString());
},
}));
modPackCommands.add(new lib.Command({
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(
args: {
modPack: string,
name?: string,
description?: string,
factorioVersion?: string,
addMods?: string[],
enableMods?: string[],
disableMods?: string[],
removeMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackModsEnabled(modPack, args.disableMods, false);
setModPackModsEnabled(modPack, args.enableMods, true);
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = args.removeSetting.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
if (modPack.settings[checkedScope].has(name)) {
modPack.settings[checkedScope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await control.send(new lib.ModPackUpdateRequest(modPack));
},
}));
modPackCommands.add(new lib.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const id = await lib.resolveModPack(control, args.modPack);
await control.send(new lib.ModPackDeleteRequest(id));
},
}));
-16
View File
@@ -1,16 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new lib.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args: object, control: Control) {
let permissions = await control.send(new lib.PermissionListRequest());
print(asTable(permissions));
},
}));
-100
View File
@@ -1,100 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
roleCommands.add(new lib.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args: object, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
print(asTable(roles));
},
}));
roleCommands.add(new lib.Command({
definition: ["create <name>", "Create a new role", (yargs) => {
yargs.positional("name", { describe: "Name of role to create", type: "string" });
yargs.options({
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args: { name: string, description: string, permissions: string[] }, control: Control) {
let id = await control.send(new lib.RoleCreateRequest(
args.name,
args.description,
args.permissions,
));
logger.info(`Created role ID ${id}`);
},
}));
roleCommands.add(new lib.Command({
definition: ["edit <role>", "Edit existing role", (yargs) => {
yargs.positional("role", { describe: "Role to edit", type: "string" });
yargs.options({
"name": { describe: "New name for role", nargs: 1, type: "string" },
"description": { describe: "New description for role", nargs: 1, type: "string" },
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
"remove-perms": {
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
},
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: {
role: string,
name?: string,
description?: string,
setPerms?: string[],
addPerms?: string[],
removePerms?: string[],
grantDefault?: boolean,
},
control: Control
) {
let role = await lib.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
}
if (args.description !== undefined) {
role.description = args.description;
}
if (args.addPerms) {
for (const perm of args.addPerms) {
role.permissions.add(perm);
}
}
if (args.removePerms) {
for (let perm of args.removePerms) {
role.permissions.delete(perm);
}
}
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
roleCommands.add(new lib.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args: { role: string }, control: Control) {
let role = await lib.retrieveRole(control, args.role);
await control.send(new lib.RoleDeleteRequest(role.id));
},
}));
-304
View File
@@ -1,304 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" });
userCommands.add(new lib.Command({
definition: ["show <name>", "Show details for one user", (yargs) => {
yargs.positional("name", { decribe: "Name of user to show", type: "string" });
yargs.options({
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
let instanceStats = user.instanceStats;
// @ts-expect-error Terrible hack
delete user.instanceStats;
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await control.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
return instance.name;
}
return "<deleted>";
}
for (let [id, playerInstanceStats] of instanceStats || []) {
print();
print(`Instance ${instanceName(id)} (${id}):`);
print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value }))));
}
}
},
}));
userCommands.add(new lib.Command({
definition: [["list", "l"], "List user in the cluster", (yargs) => {
yargs.options({
"stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false },
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) {
let users = await control.send(new lib.UserListRequest());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
}
// @ts-expect-error Terrible hack
delete user.playerStats;
// @ts-expect-error Terrible hack
delete user.isDeleted;
// @ts-expect-error Terrible hack
delete user.banReason;
// @ts-expect-error Terrible hack
delete user.instanceStats;
if (!args.attributes) {
// @ts-expect-error Terrible hack
delete user.isAdmin;
// @ts-expect-error Terrible hack
delete user.isWhitelisted;
// @ts-expect-error Terrible hack
delete user.isBanned;
}
}
print(asTable(users));
},
}));
userCommands.add(new lib.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserCreateRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserRevokeTokenRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
yargs.options({
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, revoke: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
},
}));
userCommands.add(new lib.Command({
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
yargs.options({
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, remove: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
},
}));
userCommands.add(new lib.Command({
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
yargs.options({
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
user: string,
pardon: boolean,
reason: string,
create: boolean
},
control: Control
) {
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
userCommands.add(new lib.Command({
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args: { user: string, roles: string[] }, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
let resolvedRoles = [];
for (let roleName of args.roles) {
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
resolvedRoles.push(roleId);
} else {
let found = false;
for (let role of roles) {
if (role.name === roleName) {
resolvedRoles.push(role.id);
found = true;
break;
}
}
if (!found) {
throw new lib.CommandError(`No role named ${roleName}`);
}
}
}
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
userCommands.add(new lib.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args: { user: string }, control: Control) {
await control.send(new lib.UserDeleteRequest(args.user));
},
}));
async function handleImportOrRestore(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, restore: boolean) {
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
const filename = path.basename(args.filepath);
if (args.users || (optionCount === 0 && data.export_version)) {
importType = "users";
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
importType = "bans";
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
importType = "admins";
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
importType = "whitelist";
} else {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
userCommands.add(new lib.Command({
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
userCommands.add(new lib.Command({
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
},
}));
userCommands.add(new lib.Command({
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
yargs.options({
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
const optionCount = [args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
if (args.users) {
exportType = "users";
} else if (args.bans) {
exportType = "bans";
} else if (args.admins) {
exportType = "admins";
} else if (args.whitelist) {
exportType = "whitelist";
}
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
-58
View File
@@ -1,58 +0,0 @@
import * as lib from "@clusterio/lib";
export async function getEditor(argsEditor: string) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
export async function configToKeyVal(data: string) {
let final: Record<string, string> = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
let split = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = split[1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[finalIndex] = part;
}
}
return final;
}
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
if (name in disallowedList) {
continue;
}
let desc = "";
try {
desc += (configGroup.fieldDefinitions as any)[name]!.description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${name} = ${value}\n\n`;
}
return allConfigElements;
}
+15 -17
View File
@@ -51,11 +51,13 @@ void new lib.Gauge(
export class HostConnector extends lib.WebSocketClientConnector {
constructor(
public hostConfig: lib.HostConfig,
tlsCa: string | undefined,
public pluginInfos: lib.PluginNodeEnvInfo[]
) {
super(
hostConfig.get("host.controller_url"),
hostConfig.get("host.max_reconnect_delay"),
tlsCa
);
}
@@ -111,7 +113,6 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
@@ -166,14 +167,8 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
const hostConfigPath = args.config;
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
@@ -191,13 +186,10 @@ async function startHost() {
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
await lib.handleConfigCommand(args, hostConfig);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
@@ -228,10 +220,17 @@ async function startHost() {
return;
}
let hostConnector = new HostConnector(hostConfig, pluginInfos);
let tlsCa: string | undefined;
let tlsCaPath = hostConfig.get("host.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
host = new Host(
hostConnector,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
Boolean(args.recovery),
@@ -244,7 +243,7 @@ async function startHost() {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -257,7 +256,7 @@ async function startHost() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -282,7 +281,6 @@ async function startHost() {
});
await hostConnector.connect();
host.startedAtMs = Date.now();
logger.info("Started host");
}
@@ -299,7 +297,7 @@ I version of clusterio. Expect things to break. I
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
} else if (err instanceof lib.StartupError || err.code === "InstallationError") {
} else if (err instanceof lib.StartupError) {
logger.fatal(`
+----------------------------------+
| Unable to to start clusteriohost |
@@ -70,6 +70,7 @@ function serialize.serialize_equipment_grid(grid)
if equipment.to_be_removed then
entry.t = equipment.to_be_removed
end
log(entry)
table.insert(serialized, entry)
end
end
+13 -13
View File
@@ -3,11 +3,10 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -18,32 +17,33 @@
"factorio"
],
"engines": {
"node": ">=20"
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.3.0",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
"typescript": "^5.7.3"
}
}
+280 -162
View File
@@ -3,8 +3,10 @@ import path from "path";
import events from "events";
import pidusage from "pidusage";
import setBlocking from "set-blocking";
import phin from "phin";
import stream from "stream";
import util from "util";
import child_process from "child_process";
// internal libraries
import * as lib from "@clusterio/lib";
@@ -31,7 +33,7 @@ function checkRequestSaveName(name: string) {
*
* Looks through all sub-dirs of the provided directory for valid
* instance definitions and return a mapping of instance id to
* instance configs and the root instance paths.
* instanceInfo objects.
*
* @param instancesDir - Directory containing instances
* @returns
@@ -39,7 +41,7 @@ function checkRequestSaveName(name: string) {
* @internal
*/
async function discoverInstances(instancesDir: string) {
const instances = new Map<number, UnloadedInstance>();
let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
@@ -59,25 +61,21 @@ async function discoverInstances(instancesDir: string) {
continue;
}
if (instances.has(instanceConfig.get("instance.id"))) {
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instances.set(instanceConfig.get("instance.id"), {
instanceInfos.set(instanceConfig.get("instance.id"), {
path: instancePath,
config: instanceConfig,
});
}
}
return instances;
return instanceInfos;
}
// Requests that wake up instances are responsible for making sure the
// instance status is correctly updated even if they fail with an error. This
// means they should typically use try catch on anything that can throw
// and call notifyExit upon any errors.
const instanceStartingMessages = new Set([
lib.InstanceStartRequest.name,
lib.InstanceLoadScenarioRequest.name,
@@ -117,10 +115,10 @@ export class HostRouter {
dst.type === lib.Address.controller
|| dst.type === lib.Address.control
|| dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id")
|| dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id)
|| dst.type === lib.Address.instance && !this.host.instanceInfos.has(dst.id)
) {
nextHop = this.host;
} else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) {
} else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) {
nextHop = this.host.instanceConnections.get(dst.id)!;
}
@@ -247,33 +245,28 @@ export class HostRouter {
}
}
// Instance information required to location and load an instance
type UnloadedInstance = {
path: string,
config: lib.InstanceConfig,
};
/**
* Handles running the host
*
* Connects to the controller over the WebSocket and manages intsances.
*/
export default class Host extends lib.Link {
declare "connector": HostConnector;
declare ["connector"]: HostConnector;
router = new HostRouter(this);
/**
* Certificate authority used to validate TLS connections to the controller.
*/
tlsCa?: string;
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** 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>();
/** A map of instances that exist in the instance directory of the host */
discoveredInstances = new Map<number, UnloadedInstance>();
/** A map of instances that are assigned to this host */
assignedInstances = new Map<number, UnloadedInstance>();
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
@@ -286,8 +279,6 @@ export default class Host extends lib.Link {
_disconnecting = false;
_shuttingDown = false;
startedAtMs = Date.now();
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
@@ -303,6 +294,7 @@ export default class Host extends lib.Link {
constructor(
connector: HostConnector,
hostConfig: lib.HostConfig,
tlsCa: string | undefined,
pluginInfos: lib.PluginNodeEnvInfo[],
/**
* If true indicates that there is a process monitor present that
@@ -317,6 +309,7 @@ export default class Host extends lib.Link {
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
) {
super(connector);
this.tlsCa = tlsCa;
this.pluginInfos = pluginInfos;
this.config = hostConfig;
@@ -392,6 +385,8 @@ export default class Host extends lib.Link {
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
this.handle(lib.HostFactorioVersionsRequest, this.handleHostFactorioVersionsRequest.bind(this));
this.handle(lib.HostDownloadFactorioRequest, this.handleHostDownloadFactorioRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
@@ -418,7 +413,7 @@ export default class Host extends lib.Link {
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
) {
continue;
}
@@ -444,9 +439,6 @@ export default class Host extends lib.Link {
this.plugins.set(pluginInfo.name, hostPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -591,17 +583,18 @@ 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`);
}
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
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);
response.pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
@@ -660,16 +653,16 @@ export default class Host extends lib.Link {
async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) {
let { instanceId, config } = request;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
instance.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance));
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
instanceInfo.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo));
} else {
instance = this.discoveredInstances.get(instanceId);
if (instance) {
await Instance.populate_folders(instance.path);
instance.config.update(config, true, "controller");
instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (instanceInfo) {
await Instance.populate_folders(instanceInfo.path);
instanceInfo.config.update(config, true, "controller");
} else {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
@@ -680,16 +673,16 @@ export default class Host extends lib.Link {
logger.info(`Creating ${instanceDir}`);
await Instance.populate_folders(instanceDir);
instance = {
instanceInfo = {
path: instanceDir,
config: instanceConfig,
};
this.discoveredInstances.set(instanceId, instance);
this.discoveredInstanceInfos.set(instanceId, instanceInfo);
}
this.assignedInstances.set(instanceId, instance);
logger.verbose(`assigned instance ${instance.config.get("instance.name")}`);
this.instanceInfos.set(instanceId, instanceInfo);
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
}
// Somewhat hacky, but in the event of a lost session the status is
@@ -703,46 +696,45 @@ export default class Host extends lib.Link {
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
// TODO: factorio.version is not validated
: instance.config.get("factorio.version") as lib.TargetVersion,
: instanceInfo.config.get("factorio.version"),
)
);
// Send the new list of saves for this assigned instance to the controller.
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
// save a copy of the instance config
await instance.config.save();
await instanceInfo.config.save();
}
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
let instanceId = request.instanceId;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) {
await instanceConnection.send(new lib.InstanceStopRequest());
}
this.assignedInstances.delete(instanceId);
logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`);
this.instanceInfos.delete(instanceId);
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
}
}
instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) {
instance = instance || this.assignedInstances.get(instanceId);
if (!instance) {
instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
instanceInfo = instanceInfo || this.instanceInfos.get(instanceId);
if (!instanceInfo) {
return { instance_id: instanceId, instance_name: String(instanceId) };
}
return { instance_id: instanceId, instance_name: instance.config.get("instance.name") };
return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") };
}
getRequestInstance(instanceId: number) {
let instance = this.assignedInstances.get(instanceId);
if (!instance) {
getRequestInstanceInfo(instanceId: number) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
return instanceInfo;
}
/**
@@ -751,7 +743,7 @@ export default class Host extends lib.Link {
* @returns Assigned game port or undefined if it does not exist.
*/
gamePort(instanceId: number) {
const instance = this.discoveredInstances.get(instanceId);
const instance = this.discoveredInstanceInfos.get(instanceId);
if (!instance) {
return undefined;
}
@@ -764,7 +756,7 @@ export default class Host extends lib.Link {
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const [id, instance] of this.assignedInstances) {
for (const [id, instance] of this.instanceInfos) {
if (id === instanceId) {
continue;
}
@@ -776,7 +768,7 @@ export default class Host extends lib.Link {
availablePorts.delete(port);
}
}
const instanceToAssign = this.assignedInstances.get(instanceId)!;
const instanceToAssign = this.instanceInfos.get(instanceId)!;
const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port");
if (assignedPort !== null && availablePorts.has(assignedPort)) {
return assignedPort;
@@ -796,7 +788,7 @@ export default class Host extends lib.Link {
* @returns connection to instance.
*/
async _connectInstance(instanceId: number) {
let unloadedInstance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
@@ -805,9 +797,7 @@ export default class Host extends lib.Link {
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
let instance = new Instance(
this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
unloadedInstance.config
this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config
);
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
@@ -818,48 +808,19 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/host";
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("host.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
}
async handleHostMetricsRequest() {
@@ -901,24 +862,178 @@ export default class Host extends lib.Link {
return { results };
}
async handleHostFactorioVersionsRequest() {
const factorioDir = this.config.get("host.factorio_directory");
const versions: string[] = [];
try {
// Check if factorioDir is a simple installation
const simpleVersion = await this.getFactorioVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
versions.push(simpleVersion);
} else {
// Check for multiple versions in subdirectories
const entries = await fs.readdir(factorioDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const version = await this.getFactorioVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version !== null) {
versions.push(version);
}
}
}
} catch (error: any) {
if (error.code !== "ENOENT") {
logger.warn(`Error reading Factorio directory: ${error.message}`);
}
}
// Sort versions in descending order (newest first)
versions.sort((a, b) => {
const aParts = a.split(".").map(s => parseInt(s, 10));
const bParts = b.split(".").map(s => parseInt(s, 10));
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0;
const bPart = bParts[i] || 0;
if (aPart !== bPart) {
return bPart - aPart;
}
}
return 0;
});
return new lib.HostFactorioVersionsRequest.Response(versions);
}
async handleHostDownloadFactorioRequest() {
try {
const factorioDir = this.config.get("host.factorio_directory");
// Fetch the latest version info
const res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const location = res.headers.get("location");
if (!location) {
throw new Error("No redirect location found in response headers");
}
const url = new URL(location);
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 new Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = path.join(tmpDir, filename);
const tmpArchivePath = `${archivePath}.tmp`;
const versionDir = path.join(factorioDir, version);
const tmpFactorioDir = path.join(tmpDir, version);
// Check if version already exists
if (await fs.pathExists(versionDir)) {
return new lib.HostDownloadFactorioRequest.Response(true, version);
}
await fs.ensureDir(tmpDir);
// Download the factorio archive
const downloadRes = await fetch(url.href);
if (!downloadRes.ok) {
throw new Error(`Failed to download Factorio: ${downloadRes.statusText}`);
}
logger.info(`Downloading Factorio ${version}. This may take a while.`);
const writeStream = fs.createWriteStream(tmpArchivePath);
if (downloadRes.body) {
const readableStream = stream.Readable.fromWeb(downloadRes.body);
readableStream.pipe(writeStream);
await finished(writeStream);
} else {
throw new Error("No response body received from download");
}
await fs.rename(tmpArchivePath, archivePath);
// Extract the archive
await fs.ensureDir(tmpFactorioDir);
await this.execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
// Clean up archive and move to final location
await fs.unlink(archivePath);
await fs.rename(tmpFactorioDir, versionDir);
logger.info(`Successfully downloaded and installed Factorio ${version}`);
return new lib.HostDownloadFactorioRequest.Response(true, version);
} catch (error: any) {
logger.error(`Failed to download Factorio: ${error.message}`);
return new lib.HostDownloadFactorioRequest.Response(false, undefined, error.message);
}
}
private async getFactorioVersion(changelogPath: string): Promise<string | null> {
try {
const changelog = await fs.readFile(changelogPath, "utf-8");
for (const line of changelog.split(/[\r\n]+/)) {
const index = line.indexOf(":");
if (index !== -1) {
const name = line.slice(0, index).trim();
if (name.toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
}
return null;
} catch (error: any) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
private async execFile(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
child_process.execFile(
cmd,
args,
{ shell: true },
(err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
}
);
});
}
async fallbackInstanceSaveDetailsListRequest(
request: lib.InstanceSaveDetailsListRequest,
src: lib.Address,
dst: lib.Address
) {
let instance = this.getRequestInstance(dst.id);
return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null);
let instanceInfo = this.getRequestInstanceInfo(dst.id);
return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null);
}
async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) {
let { instanceId, oldName, newName } = request;
checkRequestSaveName(oldName);
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.move(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
path.join(instanceInfo.path, "saves", oldName),
path.join(instanceInfo.path, "saves", newName),
{ overwrite: false },
);
} catch (err: any) {
@@ -927,18 +1042,18 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) {
let { instanceId, source, destination } = request;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.copy(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err: any) {
@@ -947,33 +1062,33 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
checkRequestSaveName(sourceName);
checkRequestSaveName(targetName);
let sourceInstance = this.getRequestInstance(sourceInstanceId);
let targetInstance = this.getRequestInstance(targetInstanceId);
let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId);
let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
targetName = await lib.findUnusedName(
path.join(targetInstance.path, "saves"), targetName, ".zip"
path.join(targetInstanceInfo.path, "saves"), targetName, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
{ overwrite: true },
);
}
@@ -983,8 +1098,8 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves"));
return targetName;
}
@@ -1004,34 +1119,38 @@ export default class Host extends lib.Link {
async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) {
let { instanceId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.unlink(path.join(instance.path, "saves", name));
await fs.unlink(path.join(instanceInfo.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
if (response.statusCode !== 200) {
let content = await lib.readStream(response);
throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
}
let savesDir = path.join(instance.path, "saves");
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
@@ -1047,7 +1166,7 @@ export default class Host extends lib.Link {
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
response.pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
@@ -1060,13 +1179,12 @@ export default class Host extends lib.Link {
async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let content = fs.createReadStream(path.join(instance.path, "saves", name));
let content: Buffer;
try {
// 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");
// phin doesn't support streaming requests :(
content = await fs.readFile(path.join(instanceInfo.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1076,10 +1194,10 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
fetch(url, {
method: "PUT",
body: content,
duplex: "half",
phin({
url, method: "PUT",
core: { ca: this.tlsCa } as object,
data: content,
}).catch(err => {
logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId));
});
@@ -1091,21 +1209,21 @@ export default class Host extends lib.Link {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let instance = this.discoveredInstances.get(instanceId);
if (!instance) {
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (!instanceInfo) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.remove(instance.path);
this.discoveredInstanceInfos.delete(instanceId);
this.instanceInfos.delete(instanceId);
await fs.remove(instanceInfo.path);
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return await lib.updatePackage("@clusterio/host");
return lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1126,7 +1244,7 @@ export default class Host extends lib.Link {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.plugins.has(pluginInfo.name),
this.config.get(`${pluginInfo.name}.load_plugin`),
this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean,
));
}
@@ -1148,12 +1266,12 @@ export default class Host extends lib.Link {
* the host and controller with the new list of instances.
*/
async updateInstances() {
this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory"));
this.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory"));
let list = [];
for (let [instanceId, instance] of this.discoveredInstances) {
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push(new lib.HostInstanceUpdate(
instance.config.toRemote("controller"),
instanceInfo.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
@@ -1165,10 +1283,10 @@ export default class Host extends lib.Link {
if (this._startup) {
this._startup = false;
for (let [instanceId, instance] of this.assignedInstances) {
if (instance.config.get("instance.auto_start")) {
for (let [instanceId, instanceInfo] of this.instanceInfos) {
if (instanceInfo.config.get("instance.auto_start")) {
if (this.recoveryMode) {
logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`);
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
continue;
}
try {
@@ -1176,8 +1294,8 @@ export default class Host extends lib.Link {
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
} catch (err: any) {
logger.error(
`Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instance)
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instanceInfo)
);
}
}
@@ -1225,7 +1343,7 @@ export default class Host extends lib.Link {
await this.config.save();
// Save host side in case host_assigned_game_port changed in the config.
await Promise.all(
[...this.assignedInstances.values()].map(instance => instance.config.save())
[...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save())
);
try {
@@ -1240,7 +1358,7 @@ export default class Host extends lib.Link {
+------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
}
+15 -45
View File
@@ -1,6 +1,7 @@
import fs from "fs-extra";
import path from "path";
import pidusage from "pidusage";
import phin from "phin";
import util from "util";
import type { Static } from "@sinclair/typebox";
import { exec } from "child_process";
@@ -209,8 +210,7 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
version: this.config.get("factorio.version"),
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
@@ -289,7 +289,6 @@ export default class Instance extends lib.Link {
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this));
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
@@ -553,8 +552,7 @@ end`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
status === "running"? this.server.version : this.config.get("factorio.version"),
),
);
}
@@ -637,12 +635,9 @@ end`.replace(/\r?\n/g, " ");
}
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
// After changing the status this function is responsible for calling notifyExit if it throws.
this.notifyStatus("starting");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
this.notifyExit();
@@ -655,7 +650,7 @@ end`.replace(/\r?\n/g, " ");
if (
!pluginInfo.instanceEntrypoint
|| !this._host.serverPlugins.has(pluginInfo.name)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
) {
continue;
}
@@ -845,7 +840,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
this.logger.verbose("Writing server-adminlist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._host.adminlist], null, "\t")
);
@@ -853,7 +848,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_banlist") !== "disabled") {
this.logger.verbose("Writing server-banlist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._host.banlist].map(
([username, reason]) => ({ username, reason })
@@ -863,21 +858,10 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
this.logger.verbose("Writing server-whitelist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._host.whitelist], null, "\t")
);
} else {
// Always create the whitelist even if unused
// This allows it to be watched for changes without a server restart
try {
// wx will throw an error if the file already exists to prevent overwrite
await fs.writeFile(this.server.writePath("server-whitelist.json"), "\n[]\n", { flag: "wx" });
} catch (err: any) {
if (err.code !== "EEXIST") {
throw err;
}
}
}
await this.syncMods();
@@ -975,15 +959,10 @@ end`.replace(/\r?\n/g, " ");
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_script_commands")) {
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
await this.server.disableAchievements();
}
if (this.config.get("factorio.enable_save_patching")) {
await this.updateInstanceData();
this._watchPlayerPromote();
if (this.config.get("factorio.enable_script_commands")) {
await this.updateInstanceData();
}
} else {
this._watchServerLogActions();
}
@@ -1303,18 +1282,17 @@ end`.replace(/\r?\n/g, " ");
let url = new URL(this._host.config.get("host.controller_url"));
url.pathname += "api/upload-export";
url.searchParams.set("mod_pack_id", String(this.activeModPack.id));
let response = await fetch(url, {
method: "PUT",
body: content,
duplex: "half",
let response = await phin({
url, method: "PUT",
data: content,
core: { ca: this._host.tlsCa } as object,
headers: {
"Content-Type": "application/zip",
"x-access-token": this._host.config.get("host.controller_token"),
},
});
if (response.status !== 200) {
const body = Buffer.from(await response.arrayBuffer());
throw Error(`Upload failed: ${response.status} ${response.statusText}: ${body.toString()}`);
if (response.statusCode !== 200) {
throw Error(`Upload failed: ${response.statusCode} ${response.statusMessage}: ${response.body}`);
}
} finally {
@@ -1322,14 +1300,6 @@ end`.replace(/\r?\n/g, " ");
}
}
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
await this.stop();
// Stopping invalidates this instance object, a new one must be created by the host
// To do this we simulate a waking an instance and sending a start request
const instanceConnection = await this._host._connectInstance(this.id);
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
}
async handleInstanceStopRequest() {
await this.stop();
}
+54 -156
View File
@@ -98,8 +98,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
server._logger.warn(`Skipping icon with bad mod path: ${JSON.stringify(modPath)}`);
return null;
throw new Error(`Bad mod path ${match}`);
}
let [, mod, filePath] = match;
@@ -152,6 +151,7 @@ async function loadIcon(
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
@@ -221,7 +221,7 @@ async function loadLayeredIcon(
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
x += xs;
y += ys;
@@ -258,14 +258,6 @@ function fixIcons(item: ItemPrototype) {
return item;
}
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
if (item.icons) {
return (item as LayeredIconSpecification).icons[0]?.icon;
}
return (item as SimpleIconSpecification).icon;
}
const itemTypes = new Set([
"item",
"ammo",
@@ -288,153 +280,87 @@ const itemTypes = new Set([
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
// TODO consider adding virtual signals and recipes
]);
const recipeTypes = new Set(["recipe"]);
const signalTypes = new Set(["virtual-signal"]);
const technologyTypes = new Set(["technology"]);
const planetTypes = new Set(["planet", "space-location"]);
const qualityTypes = new Set(["quality"]);
// Entity types to export. Entities whose name matches an already-exported
const entityTypes = new Set([
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
"cargo-landing-pad", "asteroid",
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
"unit", "unit-spawner", "spider-unit", "segmented-unit",
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
"simple-entity-with-owner", "simple-entity-with-force",
"arithmetic-combinator", "decider-combinator", "constant-combinator",
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
"character",
"offshore-pump", "valve", "lightning-attractor",
"fusion-generator", "fusion-reactor", "burner-generator",
"agricultural-tower", "market", "lane-splitter",
]);
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
function filterItems(prototypes: Prototypes): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type]) => allowedTypes.has(type))
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons);
}
/** Shared state for building a unified spritesheet across all categories. */
interface SheetState {
sheet: Jimp;
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
iconCache: IconCache;
simpleIcons: Map<string, number>;
pos: number;
}
const SHEET_ICON_SIZE = 32;
const SHEET_WIDTH = 1024;
async function createSheetState(): Promise<SheetState> {
return {
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
metadata: new Map(),
iconCache: new Map(),
simpleIcons: new Map(),
pos: 0,
};
}
/** Grow the spritesheet vertically if needed to fit another row. */
async function growSheet(state: SheetState) {
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
if (neededHeight > state.sheet.bitmap.height) {
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
extended.composite(state.sheet, 0, 0);
(state.sheet as any).bitmap = (extended as any).bitmap;
}
.map(fixIcons)
;
}
/**
* Export item icons for a single category onto the shared spritesheet.
* Export item icons and data
*
* @param server - The server to load icons from.
* @param modVersions - Mapping of mod name to version.
* @param items - Array of item prototypes to export.
* @param category - Category name for metadata.
* @param state - Shared spritesheet state (mutated in place).
* @returns Number of icons exported for this category.
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param server -
* The server to generate the export mod for.
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param items - Array of item prototypes.
* @return Item spritesheet and metadata.
* @internal
*/
async function exportItems(
server: FactorioServer,
modVersions: Map<string, string>,
items: ItemPrototype[],
category: string,
state: SheetState,
) {
let count = 0;
for (let item of items) {
// Skip prototypes with no icon data at all
if (!item.icons && !item.icon) {
continue;
}
async function exportItems(server: FactorioServer, modVersions: Map<string, string>, items: ItemPrototype[]) {
// Size to render icons at
let size = 32;
// Width of spritesheet
let width = 1024;
let rows = Math.ceil(items.length / (width / size));
let iconSheet = await Jimp.create(width, rows * size);
let itemData = new Map();
let pos = 0;
let iconCache: IconCache = new Map();
let simpleIcons = new Map();
for (let item of items) {
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache);
iconPos = pos;
} else {
iconPos = state.simpleIcons.get(item.icon as string);
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(item.icon as string, state.pos);
iconPos = pos;
simpleIcons.set(item.icon, pos);
}
}
}
if (iconPos !== undefined) {
const iconPath = getPrimaryIconPath(item);
state.metadata.set(`${category}.${item.name}`, {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
category,
...(iconPath ? { path: iconPath } : {}),
itemData.set(item.name, {
x: iconPos * size % width,
y: Math.floor(iconPos / (width / size)) * size,
size,
localised_name: item.localised_name,
localised_description: item.localised_description,
});
}
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
count += 1;
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
pos += 1;
}
}
return count;
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
return { iconSheet, itemData };
}
/**
@@ -579,16 +505,7 @@ export async function exportData(server: FactorioServer) {
}
}
const categories: { name: string, types: Set<string> }[] = [
{ name: "item", types: itemTypes },
{ name: "recipe", types: recipeTypes },
{ name: "signal", types: signalTypes },
{ name: "technology", types: technologyTypes },
{ name: "planet", types: planetTypes },
{ name: "quality", types: qualityTypes },
{ name: "entity", types: entityTypes },
];
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
@@ -597,28 +514,9 @@ export async function exportData(server: FactorioServer) {
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
// Build a single unified spritesheet across all prototype categories.
const state = await createSheetState();
for (const category of categories) {
const categoryItems = filterPrototypes(prototypes, category.types);
if (categoryItems.length === 0) {
continue;
}
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
server._logger.info(`Exported ${count} ${category.name} icons`);
}
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
return zip;
}
+45 -252
View File
@@ -2,7 +2,6 @@
import fs from "fs-extra";
import child_process from "child_process";
import path from "path";
import JSZip from "jszip";
import events from "events";
import util from "util";
import crypto from "crypto";
@@ -10,33 +9,31 @@ import { Rcon } from "rcon-client";
import * as lib from "@clusterio/lib";
const execFileAsync = util.promisify(child_process.execFile);
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
*
* @param factorioDir - Path factorio dir containing data/changelog.txt.
* @param changelogPath - Path to changelog.txt.
* @internal
*/
async function getFactorioVersion(factorioDir: string) {
let changelog: string;
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
async function getVersion(changelogPath: string) {
let changelog;
try {
changelog = await fs.readFile(changelogPath, "utf-8");
} catch (err: any) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
for (const line of changelog.split(/[\r\n]+/)) {
const index = line.indexOf(":");
for (let line of changelog.split(/[\r\n]+/)) {
let index = line.indexOf(":");
if (index !== -1) {
const name = line.slice(0, index).trim();
let name = line.slice(0, index).trim();
if (name.toLowerCase() === "version") {
const version = line.slice(index + 1).trim();
return lib.isFullVersion(version) ? version : null;
return line.slice(index + 1).trim();
}
}
}
@@ -69,217 +66,61 @@ function versionOrder(a: string, b: string) {
return 0;
}
/**
* Find all installed factorio versions and return them as a set
*
* @param factorioDir - Path to Factorio installation dirs.
* @returns Set of installed factorio versions
*/
async function listFactorioVersions(factorioDir: string) {
const versions = new Set<lib.FullVersion>();
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
versions.add(directVersion);
return { direct: true, versions };
}
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
continue;
}
versions.add(version);
}
return { direct: false, versions };
}
/**
* Downloads a ZIP file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractZip(url: string, targetDir: string) {
const res = await fetchDownload(url);
// Load the ZIP with JSZip
const buffer = Buffer.from(await res.arrayBuffer());
const zip = await JSZip.loadAsync(buffer);
const tmpTargetDir = `${targetDir}.tmp-extract`;
const stripRootDir = hasSingleArchiveRoot(Object.values(zip.files));
await fs.remove(tmpTargetDir);
await fs.ensureDir(tmpTargetDir);
try {
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedParts = stripRootDir ? parts.slice(1) : parts;
if (!strippedParts.length) {
return;
}
const entryPath = validatedExtractPath(tmpTargetDir, strippedParts);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
await fs.remove(targetDir);
await fs.rename(tmpTargetDir, targetDir);
} catch (err) {
await fs.remove(tmpTargetDir);
throw err;
}
}
/**
* Downloads a tar.gz file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractTar(url: string, targetDir: string) {
const res = await fetchDownload(url);
// Write the buffer to a temp file
const tmpFilePath = `${targetDir}.tmp`;
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(tmpFilePath, buffer);
await fs.ensureDir(targetDir);
// Execute tar CLI to extract
try {
await execFileAsync("tar", [
"-xf", tarPath(tmpFilePath), "-C", tarPath(targetDir), "--strip-components", "1",
]);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error("error executing tar command, do you have 'xz-utils' installed?");
}
throw err;
} finally {
// Clean up temp file
await fs.remove(tmpFilePath);
}
}
async function fetchDownload(url: string) {
const res = await fetch(url);
if (res.status === 202) {
let detail = "";
try {
detail = await res.text();
} catch {
}
throw new Error(`Download is not ready yet for ${url}${detail ? `: ${detail}` : ""}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
return res;
}
function tarPath(filePath: string) {
if (process.platform === "win32") {
return filePath.replace(/\\/g, "/");
}
return filePath;
}
function hasSingleArchiveRoot(entries: JSZip.JSZipObject[]) {
const rootNames = new Set(
entries
.map(entry => entry.name.split("/").filter(Boolean)[0])
.filter((value): value is string => Boolean(value))
);
return rootNames.size === 1;
}
function validatedExtractPath(rootDir: string, parts: string[]) {
const entryPath = path.resolve(rootDir, ...parts);
const relative = path.relative(rootDir, entryPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to extract unsafe ZIP entry ${parts.join("/")}`);
}
return entryPath;
}
function getHeadlessDownload(version: lib.ExternalFactorioVersion) {
if (process.platform === "linux") {
return { url: version.headlessUrl, extract: downloadAndExtractTar };
}
if (process.platform === "win32" && version.windowsHeadlessUrl) {
return { url: version.windowsHeadlessUrl, extract: downloadAndExtractZip };
}
return null;
}
/**
* Find factorio data directory of the given version
*
* Searches the given factorio dir for an installation of Factorio of
* the target version.
*
* @param factorioDir - Path to Factorio installation dirs.
* @param factorioDir - Path to Factorio installation dir(s).
* @param targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
* @returns Array with path to data dir and version found.
* @internal
*/
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
// Check if this is a direct installation dir
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
async function findVersion(factorioDir: string, targetVersion: string) {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
// multiple install directories
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
if (simpleVersion === targetVersion || targetVersion === "latest") {
return [path.join(factorioDir, "data"), simpleVersion];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
throw new Error(
`Factorio version ${targetVersion} was requested, but install directory contains ${simpleVersion}`
);
}
// Otherwise assume it is a directory of multiple versions
const versions = new Map<lib.FullVersion, string>();
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
let versions = new Map();
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version === null) {
continue;
}
if (version === targetVersion) {
return [path.join(factorioDir, name, "data"), version];
return [path.join(factorioDir, entry.name, "data"), version];
}
versions.set(version, name);
versions.set(version, entry.name);
}
if (!versions.size) {
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
}
if (lib.isPartialVersion(targetVersion)) {
const sorted = [...versions.keys()].sort(versionOrder);
const latest = sorted.find(version => version.startsWith(targetVersion));
if (latest) {
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
}
if (targetVersion === "latest") {
const latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
let latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest), "data"), latest];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
@@ -582,7 +423,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: lib.TargetVersion,
version?: string,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -682,7 +523,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_writeDir: string;
// Resolved in init
_version: lib.FullVersion | null = null;
_version: string | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
@@ -691,7 +532,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: lib.TargetVersion;
_targetVersion: string;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
@@ -805,7 +646,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
;
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content: unknown;
let content;
if (type === "j") {
try {
content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8"));
@@ -906,7 +747,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this._rconClient.on("authenticated", () => {
this._rconReady = true;
this.emit("rcon-ready");
});
});
this._rconClient.on("end", () => {
this._rconClient = null;
if (!this._rconReady) {
@@ -943,7 +784,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
try {
this._whitelistWatcher = fs.watch(filePath);
} catch (err: any) {
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
}
@@ -961,7 +802,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
return;
}
let newWhitelistJson: unknown;
let newWhitelistJson;
try {
newWhitelistJson = await fs.readJSON(filePath);
} catch (err: any) {
@@ -1020,57 +861,12 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this._state = "init";
}
/**
* Check of updated versions to download
*
* Should be called before init for the new version to be detected during init
*/
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
const targetVersion = this._targetVersion;
const latestVersion = availableVersions
.filter(v => (
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
))
.sort((v1, v2) => versionOrder(v1.version, v2.version))
[0];
if (!latestVersion) {
return;
}
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
const download = getHeadlessDownload(latestVersion);
if (installedVersions.direct || !download) {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
return;
}
// Download the newer version
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
const dst = path.join(this._factorioDir, latestVersion.version);
try {
await download.extract(download.url, dst);
this._logger.info(`Downloaded factorio version ${v}`);
} catch (err: unknown) {
if (installedVersions.versions.size > 0) {
this._logger.warn(
`Failed to download factorio version ${v}, continuing with installed versions: ${err}`
);
return;
}
throw err;
}
}
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
get version(): lib.FullVersion | undefined {
get version(): string | undefined {
return this._version ?? undefined;
}
@@ -1117,7 +913,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
} else if (this._unexpected.length === 0) {
let msg: string;
let msg;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
@@ -1615,12 +1411,9 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// For testing only
export const _getFactorioVersion = getFactorioVersion;
export const _getVersion = getVersion;
export const _versionOrder = versionOrder;
export const _findVersion = findVersion;
export const _listFactorioVersions = listFactorioVersions;
export const _downloadAndExtractZip = downloadAndExtractZip;
export const _downloadAndExtractTar = downloadAndExtractTar;
export const _randomDynamicPort = randomDynamicPort;
export const _generatePassword = generatePassword;
export const _parseOutput = parseOutput;
-4
View File
@@ -15,7 +15,3 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(import.meta.url);
+7 -7
View File
@@ -1,10 +1,10 @@
export interface BuildArgs {
factorioVersion?: string;
dependencies?: Record<string, string>;
clean?: boolean;
pack?: boolean;
build?: boolean;
outputDir: string;
sourceDir: string;
factorioVersion?: string;
dependencies?: Record<string, string>;
clean?: boolean;
pack?: boolean;
build?: boolean;
outputDir: string;
sourceDir: string;
};
export function build(args: BuildArgs): Promise<void>;
+2 -14
View File
@@ -115,21 +115,9 @@ async function main() {
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
"source-dir": {
describe: "Path to mod source files",
type: "string",
nargs: 1,
normalize: true,
demandOption: true,
},
"output-dir": {
describe: "Path to output built mod(s)",
type: "string",
nargs: 1,
normalize: true,
default: "dist/factorio",
},
})
.strict()
.argv
-6
View File
@@ -9,7 +9,6 @@ export * from "./src/config";
export * from "./src/data";
export * from "./src/database";
export * from "./src/errors";
export * from "./src/external";
export * from "./src/factorio";
export * from "./src/file_ops";
export * from "./src/hash";
@@ -32,12 +31,7 @@ export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export * from "./src/LockFile";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(__filename);
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.21",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -18,30 +18,30 @@
"node": ">=12"
},
"dependencies": {
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"chalk": "catalog:",
"ws": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"fast-deep-equal": "^3.1.3",
"triple-beam": "^1.4.1",
"@sinclair/typebox": "^0.30.4",
"ajv": "^8.17.1",
"chalk": "^4.1.2",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.3.0",
"jszip": "^3.10.1",
"klaw": "^4.1.0",
"set-blocking": "^2.0.0",
"triple-beam": "^1.4.1",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/set-blocking": "catalog:",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
"typescript": "^5.7.3"
}
}
-163
View File
@@ -1,163 +0,0 @@
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
* Error thrown when the lock file already exists.
*/
export class LockFileExistsError extends Error {
constructor(
readonly filePath: string
) {
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
this.name = "LockFileExistsError";
}
}
/**
* Error thrown when the acquire or release called during an invalid state.
*/
export class LockFileBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockFileBusyError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
private handle: FileHandle | null = null;
private state: LockState = "idle";
constructor(
private readonly filePath: string
) {}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
}
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
this.state = "idle";
if (err?.code === "EEXIST") {
throw new LockFileExistsError(this.filePath);
}
throw err;
}
}
/**
* Release the lock file.
* Throws LockFileBusyError if not in acquired state.
*/
async release(): Promise<void> {
if (this.state !== "acquired") {
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
}
this.state = "releasing";
this.detachExitHandlers();
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
this.handle = null;
this.state = "idle";
}
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
return false;
}
}
/**
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
}
}
/**
* Attach process handlers, called when transitioning to "acquired"
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
process.off("SIGHUP", this.handleExit);
process.off("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
} catch (err) {
// Ignored: file may have already been removed by another process
}
};
}
+72 -110
View File
@@ -3,10 +3,10 @@ import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo, ModPack } from "./data";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
super();
}
getMod(name: string, version: FullVersion, sha1?: string) {
getMod(name: string, version: string, sha1?: string) {
const mod = this.files.get(ModInfo.filename(name, version));
if (!mod || sha1 && sha1 !== mod.sha1) {
return undefined;
@@ -87,11 +87,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: FullVersion) {
async loadMod(name: string, version: string) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: FullVersion) {
async deleteMod(name: string, version: string) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -155,46 +155,10 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
return new this(modsDirectory, files);
}
/**
* Stream a url's content into a file.
*
* @param url - Web path to stream from.
* @param filePath - File path to stream to.
*/
private static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
* Download a matching version from a selection of available versions.
*
* @param releases - Array of mod releases taken from the mod portal.
* @param version - The required version to install.
* @param username - Factorio username to download using.
* @param token - Factorio token to download using.
*/
private async downloadModFromReleases(
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
username: string, token: string
) {
const selectedRelease = releases.find(release => version.testVersion(release.version));
const selectedRelease = releases.find(release => release.version === version);
if (selectedRelease === undefined) {
logger.warn(`Mod release matching version ${version} not found.`);
@@ -226,83 +190,96 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
}
}
/**
* Downloads all provided mods, does not perform chunking or filtering of builtins
*
* @param mods - An array of mods to download
* @param username - Factorio username used for auth to download mods
* @param token - Factorio token used for auth to download mods
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
private async downloadModsChunk(
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
url.searchParams.set("version", factorioVersion);
const response = await fetch(url, {
method: "POST",
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
body: new URLSearchParams({ namelist: modNames.join(",") }),
});
if (response.status !== 200) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
const mods = (await response.json() as ModsInfoResponse).results;
const modReleases = (await response.json() as ModsInfoResponse).results;
await Promise.all(modReleases.map((mod) => {
const modVersion = mods.find(m => m.name === mod.name)!.version;
await Promise.all(mods.map((mod) => {
if (mod.releases) {
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
/**
* Downloads all provided mods to the mod dir.
*
* Handles chunking to prevent errors due to URL length.
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
*
* @param mods - An array of mods to download.
* @param username - Factorio username used for auth to download mods.
* @param token - Factorio token used for auth to download mods.
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
) {
const chunkSize = 500;
const futures: Promise<void>[] = [];
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
for (let i = 0; i < mods.length; i += chunkSize) {
futures.push(
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
);
/**
*
* downloads mods to the mod dir, will not download mods for which are already in the mod directory
*
* @param modMap a map of what mods to download and what version to download
* @param username factorio username used for auth to download mods
* @param token factorio token used for auth to download mods
* @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions)
*
*/
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
const chunkSize = 500;
let futures: Promise<void>[] = [];
modMap = new Map(modMap);
// Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks
const versionParts = factorioVersion.split(".");
if (versionParts.length > 2) {
factorioVersion = versionParts.slice(0, 2).join(".");
}
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
// get rid of mods already downloaded, built-in, or core
modMap.forEach((modVersion, modName, map) => {
const filename = `${modName}_${modVersion}.zip`;
// Filter existing, built-in from getBuiltinModNames, and explicitly filter "core"
if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") {
map.delete(modName);
}
});
const modNames = Array.from(modMap.keys());
const modVersions = Array.from(modMap.values());
for (let i = 0; i < modMap.size; i += chunkSize) {
futures.push(
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize),
username, token, factorioVersion)
);
}
await Promise.all(futures);
}
/**
* Filter an array of mods based on if they are installed.
*
* @param mods - Array of mods to filer.
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
* @returns Filtered array of mods based on install state.
*/
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
const installedMods = Array.from(this.mods());
return mods.filter(mod => (
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
));
static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
@@ -355,19 +332,4 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
/**
* Fetch the full release list for a mod including dependencies.
*
* @param modName - Name of the mod to fetch the releases of
* @returns Mod portal response
*/
static async fetchModReleases(modName: string) {
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
return await response.json() as ModsInfoResponse["results"][number];
}
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Represents an async function which can fetch a value for a ValueCache
*/
export interface ValueCacheFetch<T> {
(): Promise<T>;
}
/**
* In-memory single value cache
*
* Stores the last time a value was fetched and measures staleness
* Concurrent calls will share the same in-flight fetch
*/
export default class ValueCache<T> {
private ongoingFetch: Promise<T> | null = null;
private timestamp: number | null = null;
private value: T | null = null; // Only valid when timestamp is not null
/**
* Create a new in memory single value cache
*
* @param fetch A function that fetches and returns a fresh value
*/
constructor(
private readonly fetch: ValueCacheFetch<T>,
) {}
/**
* Get the cached value, will fetch a fresh value if stale or missing
*
* @param maxAgeMs Maximum allowed cache age in milliseconds.
* Defaults to 0, meaning it will always fetch a new value.
* @throws TypeError if maxAgeMs is invalid
* @throws Propagates errors from underlying fetch method
*/
get(maxAgeMs: number = 0): Promise<T> {
if (maxAgeMs < 0) {
throw new TypeError("maxAgeMs must be a non-negative number");
}
const now = Date.now();
if (this.timestamp && now - this.timestamp < maxAgeMs) {
return Promise.resolve(this.value as T);
}
if (this.ongoingFetch) {
return this.ongoingFetch;
}
this.ongoingFetch = this.fetch()
.then(value => {
this.timestamp = Date.now();
this.value = value;
return value;
})
.finally(() => {
this.ongoingFetch = null;
});
return this.ongoingFetch;
}
}
@@ -1,21 +0,0 @@
import { InstallationError } from "./errors";
declare global {
// eslint-disable-next-line vars-on-top
var _clusterioLibImportedFrom: string;
}
export function checkSingletonImport(location: string) {
if (!global._clusterioLibImportedFrom) {
global._clusterioLibImportedFrom = location;
} else {
throw new InstallationError(`Attempt to import duplicate copy of @clusterio/lib
Importing more than one copy of @clusterio/lib into the same runtime
will break Clusterio. This typically happens when the package
manager has installed two different versions of @clusterio/lib
and assigned different ones to different components of Clusterio.
Previously imported @clusterio/lib was located at ${_clusterioLibImportedFrom}
This copy of @clusterio/lib is located at ${location}`);
}
}
+13 -10
View File
@@ -35,7 +35,6 @@ export interface ControllerConfigFields {
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -259,6 +258,7 @@ export interface HostConfigFields {
"host.instances_directory": string;
"host.controller_url": string;
"host.controller_token": string;
"host.tls_ca": string | null;
"host.public_address": string;
"host.factorio_port_range": string;
"host.factorio_username": string | null,
@@ -267,7 +267,6 @@ export interface HostConfigFields {
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -326,6 +325,12 @@ export class HostConfig extends classes.Config<HostConfigFields> {
type: "string",
initialValue: "enter token here",
},
"host.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
restartRequired: true,
type: "string",
optional: true,
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
type: "string",
@@ -393,8 +398,6 @@ export interface InstanceConfigFields {
"instance.id": number;
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
[key: `${string}.load_plugin`]: boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
@@ -462,17 +465,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "boolean",
initialValue: false,
},
"instance.exclude_from_start_all": {
description: "Exclude this instance from the 'Start all' button operation",
type: "boolean",
initialValue: false,
},
"factorio.version": {
description: "Version of the game to run, use latest to run the latest installed version",
restartRequired: true,
type: "string",
initialValue: "latest",
inputComponent: "target_version",
},
"factorio.executable_path": {
description:
@@ -617,6 +614,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
export interface ControlConfigFields {
"control.controller_url": string | null;
"control.controller_token": string | null;
"control.tls_ca": string | null;
"control.max_reconnect_delay": number;
}
@@ -639,6 +637,11 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
type: "string",
optional: true,
},
"control.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
type: "string",
optional: true,
},
"control.max_reconnect_delay": {
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
+5 -10
View File
@@ -11,26 +11,21 @@ export default class ExportManifest {
/**
* Mapping between known assets and their file names.
*
* Currently exported assets are:
* Currently exported assets are
* - settings: JSON of mod settings prototypes.
* - prototypes: JSON of all game prototypes except settings.
* - item-spritesheet: PNG containing icons from the game.
* - item-metadata: JSON of Map entries describing icons in item-spritesheet
* - locale: JSON of flattened Map entries of en locale strings from the game.
* - spritesheet: Single PNG spritesheet containing all icon categories.
* - metadata: JSON array of [name, {x, y, size, category, path?}] entries
* describing sprite coordinates and category membership. Categories:
* item, recipe, signal, technology, planet, quality, entity, static.
*/
public assets: Record<string, string>,
/** ISO 8601 timestamp of when the export was uploaded. */
public exportedAt?: string,
public assets: Record<string, string>
) { }
static jsonSchema = Type.Object({
"assets": Type.Record(Type.String(), Type.String()),
"exportedAt": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof ExportManifest.jsonSchema>) {
return new this(json.assets, json.exportedAt);
return new this(json.assets);
}
}
-77
View File
@@ -1,77 +0,0 @@
import { Type, Static } from "@sinclair/typebox";
export default class HostDetails {
constructor(
/** Version of the host software. The controller may warn if there is a mismatch. */
public version: string,
/** Human-readable display name of the host. */
public name: string,
/** Unique numeric identifier for this host. */
public id: number,
/** Whether the host is currently connected to the controller. */
public connected: boolean,
/** Address from which the host has connected to the controller. */
public remoteAddress = "",
/** Public-facing IP address of the host, if available. */
public publicAddress: string = "",
/** Unix timestamp (sec) after which issued tokens are considered valid. */
public tokenValidAfter: number = 0,
/** Unix timestamp (ms) indicating when this record was last updated. */
public updatedAtMs = 0,
/** Flag indicating the host record has been removed on the controller. */
public isDeleted = false
) {}
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted
);
}
toJSON() {
const json = {
version: this.version,
name: this.name,
id: this.id,
connected: this.connected,
} as Static<typeof HostDetails.jsonSchema>;
if (this.remoteAddress !== "") {
json.remoteAddress = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.publicAddress = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.tokenValidAfter = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.isDeleted = this.isDeleted;
}
return json;
}
}
-108
View File
@@ -1,108 +0,0 @@
import { Static, Type } from "@sinclair/typebox";
import { StringEnum } from "./composites";
import { TargetVersion, TargetVersionSchema } from "./version";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export default class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: TargetVersion | undefined,
public startedAtMs = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false
) {}
get isDeleted() {
return this.status === "deleted";
}
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(TargetVersionSchema),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
json.excludeFromStartAll
);
}
toJSON() {
const json = {
name: this.name,
id: this.id,
status: this.status,
} as Static<typeof InstanceDetails.jsonSchema>;
if (this.assignedHost !== undefined) {
json.assignedHost = this.assignedHost;
}
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.excludeFromStartAll !== false) {
json.excludeFromStartAll = this.excludeFromStartAll;
}
return json;
}
}
+17 -133
View File
@@ -5,106 +5,9 @@ import { Type, Static } from "@sinclair/typebox";
import * as libHash from "../hash";
import * as libSchema from "../schema";
import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
ModVersionEquality,
} from "./version";
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
};
const depSpecRegex = /^(?:(\?|\(\?\)|!|~|\+) *)?(.+?)(?: *([<>]=?|=) *([0-9.]+))?$/;
export class ModDependency {
public spec: string;
public name: string;
public type: ModDependencyType;
public version: ModVersionEquality | undefined;
static getTypeFromPrefix(prefix: string): ModDependencyType {
switch (prefix) {
case "!":
return "incompatible";
case "?":
return "optional";
case "(?)":
return "hidden";
case "~":
return "unordered";
case "":
return "required";
default:
throw new Error(`Invalid dependency prefix "${prefix}"`);
}
}
constructor (
specification: string,
) {
const match = depSpecRegex.exec(specification);
if (match === null) {
throw new Error(`Invalid dependency specification "${specification}"`);
}
// Expand the match to the different parts
const [spec, type, name, equality, version] = match;
this.type = ModDependency.getTypeFromPrefix(type ?? "");
this.spec = spec;
this.name = name;
// Parse the equality and version
if ((equality || version) && !this.incompatible) {
this.version = ModVersionEquality.fromParts(equality, version);
}
}
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
const mod = mods.find(m => m.name === this.name);
if (mod === undefined) {
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
} else if (this.type === "incompatible") {
return "incompatible";
} else if (this.version && !this.version.testVersion(mod.version)) {
return "wrong_version";
}
return undefined;
}
isSatisfied(mods: (ModInfo | ModRecord)[]) {
return this.checkUnsatisfiedReason(mods) === undefined;
}
get incompatible() {
return this.type === "incompatible";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
get optional() {
return this.type === "hidden" || this.type === "optional";
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.spec;
}
}
/**
* Info about a mod available on the controller.
@@ -132,13 +35,13 @@ export default class ModInfo {
* Version of the mod.
* Sourced from info.json.
*/
version = "0.0.0" as FullVersion;
version = "";
/**
* Integer representation of the version
*/
get integerVersion() {
return integerFullVersion(this.version);
return integerModVersion(this.version);
}
/**
@@ -175,38 +78,21 @@ export default class ModInfo {
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12" as "0.12" | ApiVersion;
factorioVersion = "0.12";
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerPartialVersion(this.factorioVersion);
return integerFactorioVersion(this.factorioVersion);
}
/**
* Dependencies for this mod.
* Dependiences for this mod.
* Sourced from info.json.
*/
dependencies = [new ModDependency("base")];
/**
* Dependencies for this mod.
* As they would be represented in info.json.
*/
get dependencySpecifications() {
return this.dependencies.map(d => d.spec);
}
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
return this.dependencies
.map(d => d.checkUnsatisfiedReason(mods))
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
), undefined);
}
dependencies = ["base"];
/**
* Expected name of zip file containing this mod.
@@ -221,7 +107,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: FullVersion) {
static filename(name: string, version: string) {
return `${name}_${version}.zip`;
}
@@ -251,13 +137,13 @@ export default class ModInfo {
// Content of info.json found in mod files
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": Type.String(),
"title": Type.String(),
"author": Type.String(),
"contact": Type.Optional(Type.String()),
"homepage": Type.Optional(Type.String()),
"description": Type.Optional(Type.String()),
"factorio_version": Type.Optional(ApiVersionSchema),
"factorio_version": Type.Optional(Type.String()),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -286,13 +172,7 @@ export default class ModInfo {
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
// Parse the dependencies
try {
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
} catch (err: any) {
throw new Error(`Failed for parse dependencies for ${json.name}: ${err}`);
}
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
// Additional data
if (json.size) { modInfo.size = json.size; }
@@ -315,8 +195,8 @@ export default class ModInfo {
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
json.dependencies = this.dependencies;
}
if (this.size) { json.size = this.size; }
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
@@ -347,6 +227,10 @@ export default class ModInfo {
throw new Error("Mod's info.json is not valid");
}
if (!modVersionRegExp.test(modInfo.version)) {
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
}
const stat = await fs.stat(modPath);
return this.fromJSON({
...modInfo,
+20 -31
View File
@@ -5,15 +5,9 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import { integerFactorioVersion } from "./version";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
} from "./version";
export const ModSettingColor = Type.Object({
r: Type.Number(),
@@ -46,21 +40,17 @@ export interface ModRecord {
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod. */
version: FullVersion,
version: string,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
error?: "missing" | "bad_checksum",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
info?: ModInfo,
error?: "missing"|"bad_checksum",
}
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": FullVersionSchema,
"version": Type.String(),
"sha1": Type.Optional(Type.String()),
});
@@ -92,13 +82,13 @@ export default class ModPack {
/**
* Version of Factorio this mod pack is for
*/
factorioVersion = "1.1.0" as PartialVersion;
factorioVersion = "1.1.0";
/**
* Integer representation of the factorioVersion
*/
get integerFactorioVersion() {
return integerPartialVersion(this.factorioVersion);
return integerFactorioVersion(this.factorioVersion);
}
/**
@@ -146,7 +136,7 @@ export default class ModPack {
"id": Type.Optional(Type.Integer()),
"name": Type.String(),
"description": Type.String(),
"factorio_version": PartialVersionSchema,
"factorio_version": Type.String(),
"mods": Type.Array(ModRecordJsonSchema),
"settings": Type.Object({
"startup": ModSettingsJsonSchema,
@@ -171,7 +161,7 @@ export default class ModPack {
if (json.name) { modPack.name = json.name; }
if (json.description) { modPack.description = json.description; }
if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; }
if (json.mods) { modPack.mods = new Map(json.mods.map((m: ModRecord) => [m.name, m])); }
if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); }
if (json.settings) {
modPack.settings = {
"startup": new Map(Object.entries(json.settings["startup"])),
@@ -228,7 +218,7 @@ export default class ModPack {
let buf = Buffer.from(modPackString, "base64");
try {
// eslint-disable-next-line node/no-sync
buf = Buffer.from(zlib.inflateSync(buf));
buf = zlib.inflateSync(buf);
} catch (err: any) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed mod pack string: zlib inflate failed");
@@ -301,7 +291,7 @@ export default class ModPack {
const sizeBytes = uint32Bytes(entries.length);
const itemBytes = entries.flatMap(([key, item]) => [
immutableStringBytes(key),
// eslint-disable-next-line no-use-before-define
propertyTreeBytes(item),
]);
return Buffer.concat([
@@ -395,40 +385,39 @@ export default class ModPack {
/** Array of default mod packs which should exist on a newly installed cluster */
static defaultModPacks = [
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
...["0.17", "0.18", "1.0", "1.1", "2.0"]
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
factorio_version: version,
} as any)),
...(["2.0"] as const)
...["2.0"]
.map(version => this.fromJSON({
name: `Space Age ${version}`,
description: `Factorio ${version} with Space Age expansion.`,
factorio_version: version,
mods: [
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
{ name: "elevated-rails", enabled: true, version },
{ name: "quality", enabled: true, version },
{ name: "space-age", enabled: true, version },
],
} as any)),
];
/**
* Returns an array of ModRecord containing all mods included in factorio
* Returns an array of ModInfo containing all mods included in factorio
*
* @param factorioVersion - Factorio version to get the default mods for
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: PartialVersion) {
factorioVersion = normaliseFullVersion(factorioVersion);
static getBuiltinMods(factorioVersion: string) {
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: factorioVersion },
];
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
const integerVersion = integerFactorioVersion(factorioVersion);
if (integerVersion >= integerFactorioVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
@@ -439,7 +428,7 @@ export default class ModPack {
return defaultMods;
}
static getBuiltinModNames(factorioVersion: PartialVersion) {
static getBuiltinModNames(factorioVersion: string) {
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
}
}
+1 -27
View File
@@ -1,23 +1,15 @@
import { Static, Type } from "@sinclair/typebox";
import { permissions as globalPermissions } from "../permissions";
interface RoleCollection {
get(id: number): Role;
set(role: Role): void;
}
/**
* Represents a collection of granted permissions
*/
export default class Role {
static DefaultAdminRoleId = 0 as const;
static DefaultPlayerRoleId = 1 as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions = new Set<string>(),
public permissions: Set<string>,
public updatedAtMs = 0,
public isDeleted = false,
) { }
@@ -53,13 +45,6 @@ export default class Role {
};
}
static ensureDefaultPlayerRole(roles: RoleCollection) {
const playerRole = roles.get(this.DefaultPlayerRoleId)
?? new this(this.DefaultPlayerRoleId, "Player", "Default player role.");
playerRole.grantDefaultPermissions();
roles.set(playerRole);
}
grantDefaultPermissions() {
for (let permission of globalPermissions.values()) {
if (permission.grantByDefault) {
@@ -67,15 +52,4 @@ export default class Role {
}
}
}
static ensureDefaultAdminRole(roles: RoleCollection) {
const adminRole = roles.get(this.DefaultAdminRoleId)
?? new this(this.DefaultAdminRoleId, "Cluster Admin", "Cluster wide administrator.");
adminRole.grantAdminPermissions();
roles.set(adminRole);
}
grantAdminPermissions() {
this.permissions.add("core.admin");
}
}
@@ -2,10 +2,12 @@ import { Static, Type } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
/** Represents a user in the cluster */
export default class UserDetails {
public playerStats: PlayerStats;
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
*/
export default class User {
constructor(
/** Factorio user name. */
public name: string,
@@ -25,15 +27,11 @@ export default class UserDetails {
public updatedAtMs = 0,
/** True if this user object has been removed from the cluster. */
public isDeleted = false,
/** Combined statistics for the player this user account is tied to. */
public playerStats = new PlayerStats(),
/** Per instance statistics for the player this user account is tied to. */
public instanceStats = new Map<number, PlayerStats>(),
) {
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
static jsonSchema = Type.Object({
@@ -53,13 +51,17 @@ export default class UserDetails {
),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
static fromJSON(json: Static<typeof this.jsonSchema>, _hack?: any) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(([id, stats]) => [id, PlayerStats.fromJSON(stats)])
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
json.name,
new Set<number>(json.roles),
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
@@ -67,12 +69,13 @@ export default class UserDetails {
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON() {
let json: Static<typeof UserDetails.jsonSchema> = {
let json: Static<typeof User.jsonSchema> = {
name: this.name,
};
@@ -116,7 +119,7 @@ export default class UserDetails {
}
recalculatePlayerStats() {
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
this.playerStats = User._calculatePlayerStats(this.instanceStats);
}
static _calculatePlayerStats(instanceStatsMap: Map<number, PlayerStats>) {
@@ -146,13 +149,44 @@ export default class UserDetails {
}
return playerStats;
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: User) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isBanned = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
this.recalculatePlayerStats();
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
}
/**
* This interface is implemented by User on the controller.
* It is exposed here to allow message definitions to create permission methods.
*/
export interface IUser extends UserDetails {
export interface IControllerUser extends User {
/**
* Check if a given permission is granted
*
+3 -5
View File
@@ -4,7 +4,7 @@
* @author Hornwitser
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo, ModDependency } from "./ModInfo";
export { default as ModInfo } from "./ModInfo";
export { ModSettingColor } from "./ModPack";
export type { ModSetting, ModRecord } from "./ModPack";
export { default as ModPack } from "./ModPack";
@@ -12,10 +12,8 @@ export { default as ModuleInfo } from "./ModuleInfo";
export { default as Permission } from "./Permission";
export { default as PlayerStats } from "./PlayerStats";
export { default as Role } from "./Role";
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails";
export { default as HostDetails } from "./HostDetails";
export { default as UserDetails } from "./UserDetails";
export type { IUser } from "./UserDetails";
export { default as User } from "./User";
export type { IControllerUser } from "./User";
export * from "./composites";
export * from "./messages_core";
export * from "./messages_controller";
@@ -2,7 +2,6 @@ import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
import { ExternalFactorioVersionSchema } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -260,8 +259,6 @@ export class SystemInfo {
public diskAvailable: number,
public canRestart: boolean,
public restartRequired: boolean,
public systemStartedAtMs: number,
public processStartedAtMs: number,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number,
public isDeleted: boolean,
@@ -281,8 +278,6 @@ export class SystemInfo {
"diskAvailable": Type.Number(),
"canRestart": Type.Boolean(),
"restartRequired": Type.Boolean(),
"systemStartedAtMs": Type.Number(),
"processStartedAtMs": Type.Number(),
"updatedAtMs": Type.Number(),
"isDeleted": Type.Boolean(),
});
@@ -302,8 +297,6 @@ export class SystemInfo {
json.diskAvailable,
json.canRestart,
json.restartRequired,
json.systemStartedAtMs,
json.processStartedAtMs,
json.updatedAtMs,
json.isDeleted,
);
@@ -398,25 +391,3 @@ export class DebugWsMessageEvent {
return new this(json.direction, json.content);
}
}
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" as const;
constructor(
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
) {}
static jsonSchema = Type.Object({
"maxAgeMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Note: Run compile_validator script after making changes to this file.
import { TSchema, Type, Static } from "@sinclair/typebox";
// eslint-disable-next-line node/no-missing-import
import messageValidate from "./message_validate"; // generated file
import { StringEnum } from "./composites";
@@ -154,7 +154,7 @@ export class Message {
static validate = messageValidate;
static fromJSON(json: any) {
/* eslint-disable no-use-before-define */
if (json.type === "hello") { return MessageHello.fromJSON(json); }
if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); }
if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); }
@@ -168,7 +168,7 @@ export class Message {
if (json.type === "responseError") { return MessageResponseError.fromJSON(json); }
if (json.type === "event") { return MessageEvent.fromJSON(json); }
if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); }
/* eslint-enable no-use-before-define */
throw new Error(`Unrecognized message type ${json.type}`);
}
}
+89 -1
View File
@@ -2,7 +2,6 @@ import { Type, Static } from "@sinclair/typebox";
import { jsonArray, plainJson, StringEnum } from "./composites";
import { CollectorResultSerialized } from "../prometheus";
import { HostConfig } from "../config/definitions";
import HostDetails from "./HostDetails";
export class HostStopRequest {
declare ["constructor"]: typeof HostStopRequest;
@@ -83,6 +82,47 @@ export class HostConfigSetPropRequest {
}
}
export class HostDetails {
constructor(
public version: string,
public name: string,
public id: number,
public connected: boolean,
public remoteAddress = "",
public publicAddress: string = "",
public tokenValidAfter: number = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public isDeleted = false,
) { }
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted,
);
}
}
export class HostListRequest {
declare ["constructor"]: typeof HostListRequest;
static type = "request" as const;
@@ -248,3 +288,51 @@ export class HostRevokeTokensRequest {
return new this(json.hostId);
}
}
export class HostFactorioVersionsRequest {
declare ["constructor"]: typeof HostFactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.list" as const;
static Response = class Response {
constructor(
public versions: string[],
) { }
static jsonSchema = Type.Object({
"versions": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.versions);
}
};
}
export class HostDownloadFactorioRequest {
declare ["constructor"]: typeof HostDownloadFactorioRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update" as const;
static Response = class Response {
constructor(
public success: boolean,
public version?: string,
public error?: string,
) { }
static jsonSchema = Type.Object({
"success": Type.Boolean(),
"version": Type.Optional(Type.String()),
"error": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.success, json.version, json.error);
}
};
}
+78 -29
View File
@@ -2,11 +2,80 @@ import { Type, Static } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
import { JsonString, StringEnum, jsonArray, plainJson } from "./composites";
import { InstanceConfig } from "../config";
import type { IUser } from "./UserDetails";
import type { IControllerUser } from "./User";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
import InstanceDetails, { InstanceStatus } from "./InstanceDetails";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: string | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(Type.String()),
"updatedAtMs": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.updatedAtMs,
);
}
get isDeleted() {
return this.status === "deleted";
}
}
export class InstanceDetailsGetRequest {
declare ["constructor"]: typeof InstanceDetailsGetRequest;
@@ -215,26 +284,6 @@ export class InstanceStartRequest {
}
}
export class InstanceRestartRequest {
declare ["constructor"]: typeof InstanceRestartRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.restart" as const;
constructor(
public save?: string,
) { }
static jsonSchema = Type.Object({
"save": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.save);
}
}
export class SaveDetails {
constructor(
public instanceId: number,
@@ -463,7 +512,7 @@ export class InstanceTransferSaveRequest {
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = ["controller", "host"] as const;
static permission(user: IUser, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
user.checkPermission("core.instance.save.transfer");
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as { copy: boolean, sourceName: string, targetName: string };
@@ -672,8 +721,8 @@ export class HostInstanceUpdate {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public status: InstanceStatus,
public gamePort: number | undefined,
public factorioVersion: PartialVersion | undefined,
public gamePort: undefined | number,
public factorioVersion: undefined | string,
) { }
static jsonSchema = Type.Object({
@@ -682,7 +731,7 @@ export class HostInstanceUpdate {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(PartialVersionSchema),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -780,7 +829,7 @@ export class InstanceStatusChangedEvent {
public instanceId: number,
public status: InstanceStatus,
public gamePort?: number,
public factorioVersion?: TargetVersion,
public factorioVersion?: string,
) { }
static jsonSchema = Type.Object({
@@ -789,7 +838,7 @@ export class InstanceStatusChangedEvent {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Integer()),
"factorioVersion": Type.Optional(TargetVersionSchema),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
+30 -122
View File
@@ -1,14 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import ModInfo, { ModDependency } from "./ModInfo";
import ModInfo from "./ModInfo";
import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
ModVersionEquality,
} from "./version";
export class ModPackGetRequest {
declare ["constructor"]: typeof ModPackGetRequest;
@@ -119,13 +113,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: FullVersion,
public version: string,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": Type.String(),
"sha1": Type.Optional(Type.String()),
});
@@ -154,7 +148,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: ApiVersion,
public factorioVersion: string,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -163,7 +157,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": ApiVersionSchema,
"factorioVersion": Type.String(),
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -205,9 +199,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: FullVersionSchema,
version: Type.String(),
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
info_json: Type.Object({ factorio_version: Type.String() }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -235,13 +229,20 @@ export class ModPortalGetAllRequest {
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
// Define allowed Factorio versions
static allowedVersions = [
Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"),
Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"),
Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"),
] as const;
constructor(
public factorioVersion: ApiVersion,
public factorioVersion: Static<typeof ModPortalGetAllRequest.allowedVersions[number]>,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": ApiVersionSchema,
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -276,13 +277,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: FullVersion,
public version: string,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": Type.String(),
"sha1": Type.Optional(Type.String()),
});
@@ -302,12 +303,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: FullVersion,
public version: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -355,16 +356,6 @@ export class ModUpdatesEvent {
}
}
export interface ModNameVersionPair {
name: string,
version: ModVersionEquality,
}
export const ModNameVersionPairSchema = Type.Object({
"name": Type.String(),
"version": ModVersionEquality.jsonSchema,
});
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
@@ -372,7 +363,8 @@ export const ModNameVersionPairSchema = Type.Object({
* Requires Factorio credentials to be configured on the controller if the portal
* requires authentication for downloads.
*
* @param mods - Array of mods to be downloaded.
* @param name - Name of the mod to download.
* @param version - Version of the mod to download.
* @param factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
@@ -381,105 +373,21 @@ export class ModPortalDownloadRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = jsonArray(ModInfo);
static Response = ModInfo;
constructor(
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
public name: string,
public version: string,
public factorioVersion: string,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.mods.map(mod => ({
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
})),
json.factorioVersion,
);
return new this(json.name, json.version, json.factorioVersion);
}
}
const ModDependencyResolveErrors = ["incompatible", "unsatisfiable", "invalidDependency", "notFound"] as const;
export const ModDependencyResolveErrorsSchema = Type.Union(ModDependencyResolveErrors.map(v => Type.Literal(v)));
export type ModDependencyResolveErrors = Static<typeof ModDependencyResolveErrorsSchema>;
/**
* Request mods dependencies to be resolved using the Factorio mod portal.
*
* @param mods - Array of dependencies to resolve.
* @param factorioVersion - Factorio version context for resolution.
*/
export class ModDependencyResolveRequest {
declare ["constructor"]: typeof ModDependencyResolveRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"checkForUpdates": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
}
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static Response = class ModDependencyResolveResponse {
constructor(
public dependencies: ModInfo[],
public errors: Map<string, ModDependencyResolveErrors>,
) {}
static jsonSchema = Type.Object({
"dependencies": Type.Array(ModInfo.jsonSchema),
"errors": Type.Record(Type.String(), ModDependencyResolveErrorsSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.dependencies.map(d => ModInfo.fromJSON(d)),
new Map(Object.entries(json.errors))
);
}
toJSON(): Static<typeof ModDependencyResolveResponse.jsonSchema> {
return {
dependencies: this.dependencies.map(d => d.toJSON()),
errors: Object.fromEntries(this.errors),
};
}
};
}
+7 -7
View File
@@ -1,5 +1,5 @@
import { Type, Static } from "@sinclair/typebox";
import UserDetails, { IUser } from "./UserDetails";
import User, { IControllerUser } from "./User";
import { StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
@@ -22,7 +22,7 @@ export class UserGetRequest {
return new this(json.name);
}
static Response = UserDetails;
static Response = User;
}
export class UserListRequest {
@@ -31,7 +31,7 @@ export class UserListRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.list" as const;
static Response = jsonArray(UserDetails);
static Response = jsonArray(User);
}
export class UserCreateRequest {
@@ -198,15 +198,15 @@ export class UserUpdatesEvent {
static permission = "core.user.subscribe" as const;
constructor(
public updates: UserDetails[],
public updates: User[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(UserDetails.jsonSchema),
"updates": Type.Array(User.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => UserDetails.fromJSON(update)));
return new this(json.updates.map(update => User.fromJSON(update)));
}
}
@@ -250,7 +250,7 @@ export class UserBulkImportRequest {
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof UserBulkImportRequest.jsonSchema>;
// Check if this is a restore or import request
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more