mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6424c5411 |
No files matched your search
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
|
||||
"runtime": {
|
||||
"version": "Lua5.2",
|
||||
"requirePattern": [ "?", "?.lua" ]
|
||||
},
|
||||
"workspace": {
|
||||
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
|
||||
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
|
||||
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
|
||||
"ignoreGlobs": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"database/**",
|
||||
"docs/**",
|
||||
"external_plugins/**",
|
||||
"factorio/**",
|
||||
"instances/**",
|
||||
"logs/**",
|
||||
"mods/**",
|
||||
"modules/**",
|
||||
"temp/**",
|
||||
"packages/create/templates/**"
|
||||
],
|
||||
"moduleMap": [
|
||||
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
|
||||
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
|
||||
]
|
||||
},
|
||||
"doc": {
|
||||
"privateName": [ "__*" ]
|
||||
},
|
||||
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
|
||||
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
|
||||
"diagnostics": {
|
||||
"globals": [ "__DebugAdapter", "__Profiler" ],
|
||||
"disable": [
|
||||
"assign-type-mismatch",
|
||||
"param-type-mismatch",
|
||||
"preferred-local-alias",
|
||||
"unnecessary-assert",
|
||||
"unnecessary-if",
|
||||
"unused"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
+185
-275
@@ -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,13 +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\\.",
|
||||
"newIsCapExceptions": ["constructor"],
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
@@ -164,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",
|
||||
@@ -193,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",
|
||||
@@ -216,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",
|
||||
@@ -232,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",
|
||||
@@ -259,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",
|
||||
@@ -273,7 +299,6 @@ export default defineConfig([{
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
@@ -283,7 +308,6 @@ export default defineConfig([{
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
@@ -300,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",
|
||||
@@ -322,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: "module",
|
||||
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/**/*.?(c){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/"])]);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version passed to actions/setup-node
|
||||
required: true
|
||||
ignore-scripts:
|
||||
description: Skip the prepare scripts that build the workspace packages
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
# The lockfile is not committed, so the usual lockfile hash key does not
|
||||
# apply. Instead a new store snapshot is saved whenever a package.json
|
||||
# changes or a week passes, and every run restores the most recent one.
|
||||
- id: store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
||||
# their previous output is present. Every run saves its output and the
|
||||
# next run starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
packages/*/node_modules/.cache
|
||||
plugins/*/node_modules/.cache
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
|
||||
- shell: bash
|
||||
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
|
||||
# Drop packages the restored store had but this install did not use, so
|
||||
# the snapshot does not grow with every version bump.
|
||||
- shell: bash
|
||||
run: pnpm store prune
|
||||
@@ -1,15 +1,10 @@
|
||||
<!--
|
||||
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
|
||||
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
|
||||
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
|
||||
-->
|
||||
|
||||
### Changelog
|
||||
## Changelog
|
||||
```
|
||||
### Features
|
||||
- Added doodad to web interface. #999
|
||||
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
|
||||
|
||||
### Changes
|
||||
- ...
|
||||
@@ -17,6 +12,6 @@ If there are no relevant changelog entries replace the code block below with "No
|
||||
### Fixes
|
||||
- ...
|
||||
|
||||
### Breaking Changes
|
||||
### Breaking changes
|
||||
- ...
|
||||
```
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Lua Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Generate Factorio typedefs
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
|
||||
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
|
||||
- name: Install emmylua_check
|
||||
run: |
|
||||
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP"
|
||||
- name: Run Lint Report
|
||||
shell: bash
|
||||
run: |
|
||||
# The typedefs are generated per run, so their path is passed via a second config
|
||||
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
|
||||
'{ workspace: { library: [ $lib ] } }' \
|
||||
> "$RUNNER_TEMP/ci.emmyrc.json"
|
||||
|
||||
# Exits non zero on error severity, so the gate below is used instead
|
||||
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
|
||||
--output-format json --output check.json || true
|
||||
|
||||
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
|
||||
# Although some minor fixes were needed
|
||||
|
||||
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
code: .code,
|
||||
line: .range.start.line,
|
||||
message: (.message | split("\n")
|
||||
| map(if startswith("- ") then .[2:] else . end)
|
||||
| join("\n")),
|
||||
severity: (if .severity <= 1 then "ERR" else "WARN" end)
|
||||
} |
|
||||
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
|
||||
check.json > "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo ""
|
||||
|
||||
# Github Annotations (limited to 10)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
title: .code,
|
||||
line: .range.start.line,
|
||||
endLine: .range.end.line,
|
||||
col: .range.start.character,
|
||||
endColumn: .range.end.character,
|
||||
message: (.message | gsub("\n"; "%0A"; "m")),
|
||||
level: (if .severity <= 1 then "error" else "warning" end)
|
||||
} |
|
||||
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
|
||||
check.json
|
||||
|
||||
# An entry is emitted for every file checked, including clean ones, so
|
||||
# the gate counts diagnostics and asserts the workspace was actually read
|
||||
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
|
||||
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
|
||||
exit 1
|
||||
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -7,111 +7,70 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.x
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Supported node versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [24.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
include:
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
coverage: true
|
||||
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [20.x, 22.x]
|
||||
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.47]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Prepare and install
|
||||
run: pnpm i --no-frozen-lockfile
|
||||
- name: Use Factorio ${{ matrix.factorio-version }}
|
||||
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
|
||||
run: pnpm run ci-cover
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ matrix.coverage }}
|
||||
uses: codecov/codecov-action@v7
|
||||
- name : Upload coverage to Codecov
|
||||
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test logs for debugging
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# npm version spec for the published side of the compatibility test.
|
||||
# If each release is compatible with the one before it then testing
|
||||
# against the latest release transitively covers older versions too.
|
||||
PUBLISHED_VERSION: latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- controller: local
|
||||
host: published
|
||||
- controller: published
|
||||
host: local
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
|
||||
run: >
|
||||
node scripts/version-compat-test.js
|
||||
--controller ${{ matrix.controller }}
|
||||
--host ${{ matrix.host }}
|
||||
--published-version $PUBLISHED_VERSION
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
- name: Upload build to artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clusterio_lib
|
||||
path: ./dist
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
factorio:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
env:
|
||||
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ./dist/*
|
||||
+10
-5
@@ -10,8 +10,9 @@ pids
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directories used by tools like c8 and codecov
|
||||
# Coverage directories used by tools like nyc and codecov
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
@@ -28,16 +29,20 @@ 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
|
||||
.luarc.json
|
||||
/static/
|
||||
saves
|
||||
/temp/
|
||||
|
||||
-391
@@ -31,397 +31,6 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- Removed obsolete development-branch warnings from controller, host and ctl startup. [#1078](<https://github.com/clusterio/clusterio/pull/1078>)
|
||||
- Removed the obsolete alpha label and development warning from the Clusterio Library mod title and description. [#1079](<https://github.com/clusterio/clusterio/pull/1079>)
|
||||
- Fixed Factorio mod release uploads to use valid multipart requests and files from the requested output directory, uploading variants in version order. [#1080](<https://github.com/clusterio/clusterio/pull/1080>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@psihius](<https://github.com/psihius>)
|
||||
|
||||
## Version 2.0.0
|
||||
|
||||
### Features
|
||||
|
||||
- Mod packs now show informational notices for disabled recommended mods and available compatible updates. [#949](<https://github.com/clusterio/clusterio/issues/949>)
|
||||
- Added host, server name, save copy and start options to the create instance dialog in the web UI. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||
- Added `InstanceSaveGameRequest` and `clusterioctl instance save-game` to save the running game of an instance. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||
- Added `permissions` to `PluginDeclaration` and a `Permissions` type registry so plugin permission names are checked at compile time. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Added Update All to the web interface and `--all` to the ctl update commands, updating the core package and all npm installed plugins in one npm call. [#910](<https://github.com/clusterio/clusterio/issues/910>)
|
||||
- Added typed hook system for plugin registration. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Added `plugin install <package>` command to controller, host and ctl which installs a plugin from npm and adds it to the plugin list. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||
- Added a guard that prevents controller and host restart requests from unintentionally downgrading Clusterio. [#950](<https://github.com/clusterio/clusterio/issues/950>)
|
||||
- Added option to load static assets from a separate URL from where the controller's web API is hosted at. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||
- Added `clusteriocontroller copy-static` command to copy static web assets to a folder to ease hosting assets on a separate server. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||
- Added Mark Restart Required to the host and controller pages and matching ctl commands, for plugins installed by other means than the install request. [#1043](<https://github.com/clusterio/clusterio/pull/1043>)
|
||||
- Added the RoleViewPageBeforePermissions web extension slot for plugin components above the permissions on the role page. [#1045](<https://github.com/clusterio/clusterio/pull/1045>)
|
||||
- Added a list of permission groups to the role page, highlighting groups with granted permissions. [#1044](<https://github.com/clusterio/clusterio/pull/1044>)
|
||||
- Added `clusteriohost create-scenario` to create a scenario with the Clusterio modules patched in for use outside of Clusterio. [#1014](<https://github.com/clusterio/clusterio/pull/1014>)
|
||||
- Plugins can declare a `migrationEntrypoint` to migrate the config fields and role permissions core stores for them. Configs track the plugin version they were last loaded with in a hidden `<plugin>.version` field. [#1073](<https://github.com/clusterio/clusterio/pull/1073>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Deprecation warning DEP0190 is no longer printed when installing. [#851](<https://github.com/clusterio/clusterio/issues/851>)
|
||||
- Events sent to subscribers are no longer addressed to the request id of the request that created the subscription. [#847](<https://github.com/clusterio/clusterio/issues/847>)
|
||||
- Factorio account token no longer written to the log when a mod download fails. [#795](<https://github.com/clusterio/clusterio/issues/795>)
|
||||
- Failed downloads no longer leave a temporary file and an open file handle behind, which could shift the file names used by later downloads. [#957](<https://github.com/clusterio/clusterio/pull/957>)
|
||||
- Requests that could not be forwarded to the next hop are no longer answered a second time when the link disconnects. [#650](<https://github.com/clusterio/clusterio/issues/650>)
|
||||
- Events broadcast from an instance to all hosts are now delivered to every host instead of only the one running the instance. [#575](<https://github.com/clusterio/clusterio/issues/575>)
|
||||
- Web interface now asks to be reloaded when the controller's plugins change, instead of continuing against plugins that are no longer loaded. [#804](<https://github.com/clusterio/clusterio/issues/804>)
|
||||
- Plugin list no longer reports a plugin added since the page was opened as having failed to load. [#729](<https://github.com/clusterio/clusterio/issues/729>)
|
||||
- Instance console no longer hides the Factorio server's replies to console commands, or Factorio's own errors, when filtering to game activity. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
|
||||
- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||
- Fixed inventory_sync not preserving the personal roboport toggle, the equipment movement bonus toggle, game view settings such as alt mode, and toggleable mod shortcuts between instances. [#898](<https://github.com/clusterio/clusterio/issues/898>)
|
||||
- Schema validation errors are now included when an InvalidMessage is logged from IPC and event handlers. [#986](<https://github.com/clusterio/clusterio/issues/986>)
|
||||
- Fixed clusterioctl not being able to reference hosts, instances, mod packs and roles by an integer like name. [#886](<https://github.com/clusterio/clusterio/issues/886>)
|
||||
- Fixed the web-only plugin template referencing a permission that was never defined. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Config files are no longer world readable by default. [#1005](<https://github.com/clusterio/clusterio/issues/1005>)
|
||||
- Fixed failed recipe notification names being duplicated on every upload from the same instance in inventory sync. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||
- Instances refuse to start with a mod pack for a newer Factorio version than the instance runs, instead of Factorio silently loading default mod settings. [#1006](<https://github.com/clusterio/clusterio/issues/1006>)
|
||||
- Documented the config options missing from configuration.md, marked the internal ones and noted the validation rules. [#985](<https://github.com/clusterio/clusterio/issues/985>)
|
||||
- Fixed concurrent saves of the same file racing on a shared temporary file. [#1026](<https://github.com/clusterio/clusterio/pull/1026>)
|
||||
- Fixed control connections leaving their log subscription and debug dump listeners attached after closing. [#1027](<https://github.com/clusterio/clusterio/pull/1027>)
|
||||
- Fixed the Load Scenario modal reading the wrong field for map settings. [#1032](<https://github.com/clusterio/clusterio/pull/1032>)
|
||||
- Fixed the controller exiting when a control message's permission check threw an unexpected error. [#1033](<https://github.com/clusterio/clusterio/pull/1033>)
|
||||
- Fixed the host exiting on server log lines that look like actions but do not match the expected format. [#1034](<https://github.com/clusterio/clusterio/pull/1034>)
|
||||
- Fixed the controller crashing when a control connection with the WebSocket debug dump active was resumed. [#1035](<https://github.com/clusterio/clusterio/pull/1035>)
|
||||
- Fixed deleted users keeping their permissions on any open connection until they disconnected. [#1030](<https://github.com/clusterio/clusterio/pull/1030>)
|
||||
- Restored pagination on the instance save list. [#967](<https://github.com/clusterio/clusterio/issues/967>)
|
||||
- Mod dependency resolution now only picks releases made for the mod pack's Factorio version. [#965](<https://github.com/clusterio/clusterio/issues/965>)
|
||||
- Inventory sync no longer uploads an empty inventory over the stored one after a failed download. [#912](<https://github.com/clusterio/clusterio/issues/912>)
|
||||
- Inventory sync now releases players who leave while using a temporary inventory, instead of showing them as busy until the instance stops. [#975](<https://github.com/clusterio/clusterio/pull/975>)
|
||||
- Fixed reading map exchange strings from Factorio 2.1.13 and later, which broke `instance load-scenario --map-exchange-string` on current Factorio. [#977](<https://github.com/clusterio/clusterio/pull/977>)
|
||||
- Fixed instances getting stuck on a host after a failed assign, and instance create requests no longer accept `instance.assigned_host`. [#1029](<https://github.com/clusterio/clusterio/pull/1029>)
|
||||
- Fixed the host exiting when an instance's whitelist or server settings config changed while it was starting, or when tags contained non-string values. [#1028](<https://github.com/clusterio/clusterio/pull/1028>)
|
||||
- Fixed a host being able to modify the controller's stored save list for, or forge log messages attributed to, instances assigned to other hosts. [#1036](<https://github.com/clusterio/clusterio/pull/1036>)
|
||||
- Fixed a stop request or RCON command hanging when the Factorio process exited before its RCON connection was established. [#1031](<https://github.com/clusterio/clusterio/pull/1031>)
|
||||
- Fixed core plugins needlessly installing the web_ui, controller, host and ctl packages. [#1021](<https://github.com/clusterio/clusterio/pull/1021>)
|
||||
- Installing a plugin on a host or the controller now marks it as needing a restart. [#1042](<https://github.com/clusterio/clusterio/pull/1042>)
|
||||
- Fixed plugin manifest path resolution when loading web assets. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||
- A plugin whose web build is expected but missing is reported again: `Controller.loadJsonObject(path, true)` throws on a missing file instead of returning `{}`, and a malformed JSON file always throws. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||
- Preserve full configuration field names when using the CLI config editor. [#796](<https://github.com/clusterio/clusterio/issues/796>)
|
||||
- Plugin metrics hook handlers may return nothing. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||
- Fixed save names given when starting an instance or creating a save not being checked, allowing files outside the instance directory to be written or modified. [#1053](<https://github.com/clusterio/clusterio/pull/1053>)
|
||||
- Fixed access tokens from request headers being written to the controller and cluster logs. [#1054](<https://github.com/clusterio/clusterio/pull/1054>)
|
||||
- Fixed subscriptions and log following continuing after the user lost permission for them. [#1056](<https://github.com/clusterio/clusterio/pull/1056>)
|
||||
- Fixed web interface data staying marked as up to date after the user lost permission to view it, and not loading after permission was granted. [#1058](<https://github.com/clusterio/clusterio/pull/1058>)
|
||||
- Fixed ctl config edit showing fields that cannot be edited and leaving out field descriptions. [#1051](<https://github.com/clusterio/clusterio/issues/1051>)
|
||||
- Fixed the reason an instance failed to start being logged as "undefined". [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||
- Fixed subspace inventory combinators missing items after instance startup. [#1069](<https://github.com/clusterio/clusterio/issues/1069>)
|
||||
- Fixed the controller crashing when a WebSocket client sent an invalid frame before completing the handshake. [#1063](<https://github.com/clusterio/clusterio/issues/1063>)
|
||||
- Fixed an instance reusing a stored game port that another process holds, which made it fail to start until the port was freed. [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||
- Fixed credentials in request query parameters, such as OAuth codes, being written to the controller's HTTP log. [#1074](<https://github.com/clusterio/clusterio/pull/1074>)
|
||||
- Fixed plugin `ctlConfigFields` not being registered in the clusterioctl config. [#1075](<https://github.com/clusterio/clusterio/pull/1075>)
|
||||
- Fixed startup failures and plugin loading when installing Clusterio from published npm packages after the ES module migration. [#1070](<https://github.com/clusterio/clusterio/pull/1070>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
|
||||
- Instance console filter is now a labelled Game/Instance control instead of a single-label toggle, and Factorio's output in the log consoles is prefixed with `[server]` like every other log level. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||
- Inventory sync sends only the recipe notifications that differ from the destination instance's state on download. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||
- Log the source of responses received without a pending request, the peer of timed out connections and the number of messages resent on session resume. [#1007](<https://github.com/clusterio/clusterio/issues/1007>)
|
||||
- Plugins that are not expected to have a web build are no longer shown with an error in the plugins page. [#1004](<https://github.com/clusterio/clusterio/issues/1004>)
|
||||
- Plugins should now return a function to register handlers via hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Plugin templates from `@clusterio/create` use entrypoint functions and hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Improved typing for plugin APIs and webui extension points. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Plugin hooks run concurrently instead of one plugin at a time. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Access tokens are also accepted as an `Authorization: Bearer` header on HTTP API endpoints. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||
- `plugin install` and `plugin add` with a package name now check that they are run from a Clusterio installation directory. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||
- Bumped pnpm to 12, the GitHub Actions used by CI to their current majors, and the CI Factorio version to 2.1.17. [#1022](<https://github.com/clusterio/clusterio/pull/1022>)
|
||||
- Bumped dependencies, including eslint 10, yargs 18, chalk 6, @types/express 5 and @types/node 24. [#1023](<https://github.com/clusterio/clusterio/pull/1023>)
|
||||
- Plugin discovery now traverses dependencies only after confirming the parent package is a Clusterio plugin. [#963](<https://github.com/clusterio/clusterio/pull/963>)
|
||||
- Converted the project to using ES Modules. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||
- Converted the bundled plugins to entrypoint functions. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||
- Added `controller.max_subscription_filters` config to limit the number of filters a connection can subscribe with for one event. [#1055](<https://github.com/clusterio/clusterio/pull/1055>)
|
||||
- Added `controller.max_send_buffer_size` config to limit how much the controller buffers for a connection that does not acknowledge messages. [#1057](<https://github.com/clusterio/clusterio/pull/1057>)
|
||||
- Added links to the ExpCluster and Chat Relay community plugins in the README. [#1062](<https://github.com/clusterio/clusterio/pull/1062>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||
- `checkPermission` and the web UI `hasPermission` helpers take `PermissionName`; TypeScript plugins must augment the `Permissions` interface. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Replaced `plugins` map with `loadedPlugins` set across core classes (except WebUi). ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- The `/metrics` endpoint requires a user token with the new `core.controller.metrics` permission. Concurrent scrapes share a single gather from the hosts. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||
- Config files now have to be explicitly created. Use the `--create-config` flag to get the old behaviour of creating it if missing. [#548](<https://github.com/clusterio/clusterio/issues/548>)
|
||||
- Renamed clusterioctl's config-control subcommand to config. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Renamed clusterioctl's main class from Control to Ctl. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Renamed clusterioctl's config entries from control.[name] to ctl.[name]. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Plugin entrypoints must now specify the full filename including the `.js` extension. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||
- Renamed the inventory_sync plugin to player_sync, published as `@clusterio/plugin-player_sync`. Config fields, the inventory view permission, the controller database file and save data are migrated on load. Uninstall `@clusterio/plugin-inventory_sync` before installing the new package. [#1072](<https://github.com/clusterio/clusterio/pull/1072>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Added a `NO_EXTERNAL_TEST` flag to skip tests that use external APIs. [#976](<https://github.com/clusterio/clusterio/pull/976>)
|
||||
- Added a CI check that the controller and host stay compatible with the latest published release. [#978](<https://github.com/clusterio/clusterio/pull/978>)
|
||||
- Added a Lua lint CI job using emmylua_check. [#979](<https://github.com/clusterio/clusterio/pull/979>)
|
||||
- Added a plugin migration guide for 2.0 and documented that minor versions may contain breaking changes for the plugin API. [#1050](<https://github.com/clusterio/clusterio/pull/1050>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>), [@lntutor](<https://github.com/lntutor>), [@ColumbusLabs](<https://github.com/ColumbusLabs>), [@floze-the-genius](<https://github.com/floze-the-genius>), [@Cooldude2606](<https://github.com/Cooldude2606>), [@solarcloud7](<https://github.com/solarcloud7>), [@Hornwitser](<https://github.com/Hornwitser>), [@ILLISIS](<https://github.com/ILLISIS>), [@Danielv123](<https://github.com/Danielv123>), [@PHIDIAS0303](<https://github.com/PHIDIAS0303>)
|
||||
|
||||
## Version 2.0.0-alpha.27
|
||||
|
||||
### Features
|
||||
|
||||
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
|
||||
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
|
||||
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
|
||||
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
|
||||
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
|
||||
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
|
||||
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
|
||||
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
|
||||
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
|
||||
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>)
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.26
|
||||
|
||||
### Features
|
||||
|
||||
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
|
||||
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
|
||||
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
|
||||
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
|
||||
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
|
||||
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
|
||||
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.25
|
||||
|
||||
### Features
|
||||
|
||||
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
|
||||
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
|
||||
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
|
||||
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
|
||||
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
|
||||
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
[@Daniel](<https://github.com/Daniel>)
|
||||
[@Hornwitser](<https://github.com/Hornwitser>)
|
||||
|
||||
## Version 2.0.0-alpha.24
|
||||
|
||||
### Features
|
||||
|
||||
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
|
||||
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
|
||||
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Changes
|
||||
|
||||
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
|
||||
### Meta
|
||||
|
||||
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.23
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
|
||||
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
|
||||
|
||||
### Features
|
||||
|
||||
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
|
||||
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
|
||||
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
|
||||
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
|
||||
|
||||
### Changes
|
||||
|
||||
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
|
||||
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
|
||||
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
|
||||
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
|
||||
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
|
||||
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
|
||||
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
|
||||
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
|
||||
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
|
||||
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
|
||||
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
|
||||
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
|
||||
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
|
||||
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
|
||||
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
|
||||
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
|
||||
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Daniel](https://github.com/Danielv123)
|
||||
[@Double](https://github.com/DoubleThoughtTheProgrammer)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
[@Ryan Walker](https://github.com/solarcloud7)
|
||||
|
||||
## Version 2.0.0-alpha.22
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
|
||||
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
|
||||
|
||||
### Features
|
||||
|
||||
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
|
||||
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
|
||||
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
|
||||
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
|
||||
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
### Changes
|
||||
|
||||
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
|
||||
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
|
||||
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
|
||||
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
|
||||
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
|
||||
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
|
||||
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
|
||||
- Fixed npm update button failing to apply update and restart.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.21
|
||||
|
||||
### Major Features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:24 AS subspace_storage_builder
|
||||
FROM node:20 as subspace_storage_builder
|
||||
RUN apt update && apt install -y git
|
||||
WORKDIR /
|
||||
RUN git clone https://github.com/clusterio/subspace_storage.git
|
||||
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
|
||||
RUN npm install \
|
||||
&& node build
|
||||
|
||||
FROM node:24 AS clusterio_builder
|
||||
FROM node:20 as clusterio_builder
|
||||
RUN apt update \
|
||||
&& apt install -y wget \
|
||||
&& mkdir /clusterio
|
||||
|
||||
@@ -10,10 +10,9 @@ Discord for development/support/play: https://discord.gg/5XuDkje
|
||||
|
||||
## Important notice
|
||||
|
||||
Clusterio 2.0 is the current stable release and requires Node.js 22 or newer.
|
||||
The previous stable series is no longer supported.
|
||||
If you are upgrading from a 2.0 alpha, review the [changelog](CHANGELOG.md) before upgrading.
|
||||
Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-migration-to-2.0.md).
|
||||
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
|
||||
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
|
||||
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
|
||||
|
||||
|
||||
### Ways to support me/the project:
|
||||
@@ -76,7 +75,7 @@ These are the plugins supported and maintained by the Clusterio developers:
|
||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
|
||||
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
|
||||
- [Player Sync](/plugins/player_sync/README.md): Synchronizes your inventory and player settings between servers.
|
||||
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
|
||||
|
||||
There's also plugins developed and maintained by the community:
|
||||
|
||||
@@ -84,14 +83,9 @@ There's also plugins developed and maintained by the community:
|
||||
By Hornwitser.
|
||||
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
|
||||
Originally by Godmave, ported to 2.0 by Hornwitser.
|
||||
- [ExpCluster](https://github.com/explosivegaming/ExpCluster) (@expcluster/*): A collection of plugins and scenario features including role and group syncing, moderation tools, quality of life additions, and discord account linking.
|
||||
See repo readme for plugin breakdown. Maintained by Cooldude2606.
|
||||
- [Chat Relay](https://github.com/PHIDIAS0303/chat_relay) (@PHIDIAS0303/chat_relay): Chat relay of Discord and Factorio, seperate channel.
|
||||
|
||||
Want to make your own plugin?
|
||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
|
||||
|
||||
|
||||
## Installation
|
||||
@@ -173,12 +167,16 @@ Your instances (save files etc) will be stored there.
|
||||
### Installing Plugins
|
||||
|
||||
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
|
||||
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
|
||||
Installing plugins that are not offered by the installer consists of two steps.
|
||||
First install the package the plugin is provided by via npm, for example
|
||||
|
||||
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
|
||||
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
|
||||
Then tell clusterio that this plugin exists by adding it as a plugin.
|
||||
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
|
||||
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
|
||||
|
||||
@@ -234,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.
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import yargs from "yargs";
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
|
||||
import npmPackage from "./package.json" with { type: "json" };
|
||||
const npmPackage = require("./package.json");
|
||||
const workspaces = npmPackage.workspaces
|
||||
.map(p => p.slice(0, -2))
|
||||
.filter(p => !p.includes("external_plugins"));
|
||||
@@ -23,15 +24,13 @@ let DRY = false;
|
||||
* @param {string} filePath The path to remove.
|
||||
*/
|
||||
async function tryRemove(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
if (await fs.exists(filePath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DRY) {
|
||||
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +47,6 @@ async function removeBuildArtifacts() {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +70,7 @@ async function removeTestArtifacts() {
|
||||
async function removeExecutionArtifacts() {
|
||||
const artifacts = [
|
||||
"database", "instances", "logs", "mods",
|
||||
"config-ctl.json", "config-controller.json", "config-host.json",
|
||||
"config-control.json", "config-controller.json", "config-host.json",
|
||||
"FactorioAdminToken.txt", "plugin-list.json",
|
||||
];
|
||||
|
||||
@@ -85,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,
|
||||
@@ -105,16 +101,16 @@ 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();
|
||||
}
|
||||
|
||||
// Run main if started from command line
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
main();
|
||||
}
|
||||
+2
-41
@@ -3,10 +3,10 @@
|
||||
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
|
||||
Plugins can add their own config fields for the controller, host and instance configs.
|
||||
Plugins can add their own config fields for the controller and instances configs.
|
||||
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
||||
|
||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
||||
|
||||
|
||||
@@ -46,45 +46,6 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
|
||||
Value this config field should take in a newly initialized config.
|
||||
This can also be an async function returning the value to use.
|
||||
|
||||
**validator** (optional):
|
||||
Function called with the value being set and the config it belongs to.
|
||||
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
|
||||
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
|
||||
The config is read only while the validator runs.
|
||||
|
||||
**dependsOn** (optional):
|
||||
Names of other fields the validator reads the value of.
|
||||
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
|
||||
|
||||
**restartRequired** (optional):
|
||||
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
|
||||
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
|
||||
Defaults to false.
|
||||
|
||||
**restartRequiredProps** (optional):
|
||||
For object fields, names of properties that have the opposite of `restartRequired`.
|
||||
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
|
||||
|
||||
**credential** (optional):
|
||||
Locations allowed to read the value of this field.
|
||||
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
|
||||
|
||||
**readonly** (optional):
|
||||
Locations allowed to change the value of this field.
|
||||
Other locations can still read the value if allowed by `access`.
|
||||
|
||||
**hidden** (optional):
|
||||
True if the web interface should not show this field.
|
||||
Used for internal values that are managed by Clusterio.
|
||||
|
||||
**autoComplete** (optional):
|
||||
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
|
||||
Only used for string and number fields.
|
||||
|
||||
**inputComponent** (optional):
|
||||
Name of the input component the web interface should render for this field instead of the default for its type.
|
||||
Plugins can add their own components through `inputComponents` on their web plugin class.
|
||||
|
||||
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
||||
|
||||
|
||||
|
||||
+33
-365
@@ -1,15 +1,5 @@
|
||||
# Configuration
|
||||
|
||||
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
|
||||
|
||||
All values are checked against the type of the field when set.
|
||||
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
|
||||
Setting a value that fails a check is rejected with an error and the old value is kept.
|
||||
|
||||
A few fields are internal to Clusterio.
|
||||
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
|
||||
|
||||
|
||||
## Controller Configuration
|
||||
|
||||
@@ -30,30 +20,11 @@ This is also used by plugins to store its data.
|
||||
Defaults to "database".
|
||||
|
||||
|
||||
### controller.mods_directory
|
||||
|
||||
Directory where mods shared with the cluster are stored.
|
||||
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
|
||||
Needs to be writeable by the controller.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### controller.autosave_interval
|
||||
|
||||
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
|
||||
The data is also saved on shutdown.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.http_port
|
||||
|
||||
Port to host HTTP server on.
|
||||
If set to null no HTTP server will be exposed.
|
||||
At least one of this and controller.https_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
Defaults to 8080.
|
||||
|
||||
@@ -64,7 +35,6 @@ Port to host HTTPS server on.
|
||||
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
|
||||
If set to null no HTTPS server will be exposed.
|
||||
At least one of this and controller.http_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
|
||||
|
||||
@@ -81,56 +51,27 @@ interfaces.
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.trusted_proxies
|
||||
|
||||
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
|
||||
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
|
||||
Entries that are not valid IP addresses are logged as errors and ignored.
|
||||
|
||||
Defaults to null meaning the header is never trusted.
|
||||
|
||||
|
||||
### controller.public_url
|
||||
|
||||
Public address the controller is accessible on.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
|
||||
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
|
||||
|
||||
### controller.static_url
|
||||
|
||||
Used to override where static assets such as the web bundle is loaded from.
|
||||
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
|
||||
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
|
||||
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
|
||||
|
||||
Defaults to null meaning use the internally hosted assets.
|
||||
|
||||
|
||||
### controller.grafana_url
|
||||
|
||||
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
|
||||
If null no link is shown.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.tls_certificate
|
||||
|
||||
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
|
||||
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
|
||||
|
||||
Defaults to null.
|
||||
Defaults to "database/certificates/cert.crt".
|
||||
|
||||
|
||||
### controller.tls_private_key
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
|
||||
|
||||
Defaults to null.
|
||||
Defaults to "database/certificates/cert.key".
|
||||
|
||||
|
||||
### controller.auth_secret
|
||||
@@ -138,7 +79,6 @@ Defaults to null.
|
||||
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
|
||||
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
|
||||
Keep this secret if leaked an attacker could easily compromise the cluster.
|
||||
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
|
||||
|
||||
Defaults to null which means generate a secure secret on startup.
|
||||
|
||||
@@ -148,7 +88,6 @@ Defaults to null which means generate a secure secret on startup.
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
A lower value means less time between connections going stale and them being closed.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
@@ -160,94 +99,25 @@ A higher timeout gives more time for a client to recconect and resume an active
|
||||
In the case of the controller or client crashing the data loss is unavoidable.
|
||||
|
||||
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.max_send_buffer_size
|
||||
|
||||
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
|
||||
The controller keeps these so they can be resent if the connection drops and resumes.
|
||||
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
|
||||
Changes apply to new sessions.
|
||||
Set to 0 for no limit.
|
||||
|
||||
Defaults to 64.
|
||||
|
||||
|
||||
### controller.metrics_timeout
|
||||
|
||||
Timeout in seconds before a call to gather metrics from a host times out.
|
||||
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### controller.max_subscription_filters
|
||||
|
||||
Maximum number of filters one connection can hold for a single subscribed event.
|
||||
Subscription requests that would go over this limit are rejected.
|
||||
Set to 0 for no limit.
|
||||
Lowering the limit does not remove filters connections already hold.
|
||||
|
||||
Defaults to 10000.
|
||||
|
||||
|
||||
### controller.system_metrics_interval
|
||||
|
||||
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
|
||||
Whether the controller needs a restart to apply an update is also checked on this interval.
|
||||
If 0 system metrics are not gathered.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 10.
|
||||
|
||||
|
||||
### controller.proxy_stream_timeout
|
||||
### controller.stream_timeout
|
||||
|
||||
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
|
||||
Streams that have started sending data before this timeout is not affected.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.factorio_username
|
||||
|
||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.factorio_token
|
||||
|
||||
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
|
||||
This is the token shown on your profile page on factorio.com, not your password.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.share_factorio_credentials_with_hosts
|
||||
|
||||
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
|
||||
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
|
||||
Disable this if you don't want the controller's credentials to be sent to the hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.default_mod_pack_id
|
||||
|
||||
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
|
||||
Instances without a mod pack of their own fail to start if this is null.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.default_role_id
|
||||
|
||||
Role to automatically grant to new users.
|
||||
@@ -256,55 +126,6 @@ If null no role is granted.
|
||||
Defaults to 1 which correspond to the default Player role.
|
||||
|
||||
|
||||
### controller.mod_portal_cache_duration_minutes
|
||||
|
||||
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
|
||||
Set to 0 to disable the cache and fetch the list on every request.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 30.
|
||||
|
||||
|
||||
### controller.mod_portal_page_size
|
||||
|
||||
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 1000.
|
||||
|
||||
|
||||
### controller.allow_remote_updates
|
||||
|
||||
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.allow_plugin_updates
|
||||
|
||||
Whether remote requests to update plugins installed on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.allow_plugin_install
|
||||
|
||||
Whether remote requests to install new plugins on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### controller.version
|
||||
|
||||
Version of the controller package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the controller has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the controller.
|
||||
@@ -326,7 +147,6 @@ Defaults to "New Host".
|
||||
### host.id
|
||||
|
||||
Immutable numeric id of the host which uniquely identifies this host in the cluster.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
|
||||
@@ -339,14 +159,6 @@ This can either point to a Factorio server directory, or to a directory containi
|
||||
Defaults to "factorio".
|
||||
|
||||
|
||||
### host.mods_directory
|
||||
|
||||
Directory the host stores mods downloaded from the controller in.
|
||||
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### host.instances_directory
|
||||
|
||||
Directory to store instances in.
|
||||
@@ -366,11 +178,18 @@ Defaults to "http://localhost:8080/".
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
|
||||
The value can only be read and changed from the host itself.
|
||||
|
||||
Defaults to "enter token here".
|
||||
|
||||
|
||||
### 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.
|
||||
@@ -379,82 +198,14 @@ This is used by plugins like Server Select to give the correct address to switch
|
||||
Defaults to "localhost".
|
||||
|
||||
|
||||
### host.factorio_port_range
|
||||
|
||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
|
||||
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
|
||||
Starting an instance fails if all ports in the range are taken.
|
||||
|
||||
Defaults to "34100-34199".
|
||||
|
||||
|
||||
### host.factorio_username
|
||||
|
||||
Username of the Factorio.com account to put in the server settings of instances on this host.
|
||||
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### host.factorio_token
|
||||
|
||||
Token of the Factorio.com account to go with host.factorio_username.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### host.max_reconnect_delay
|
||||
|
||||
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### host.allow_remote_updates
|
||||
|
||||
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_updates
|
||||
|
||||
Whether remote requests to update plugins installed on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_install
|
||||
|
||||
Whether remote requests to install new plugins on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### host.version
|
||||
|
||||
Version of the host package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the host has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the host.
|
||||
Only plugins with a host or instance part have this field in the host config.
|
||||
Plugins not loaded on the host will not be loaded on the instances it hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
|
||||
### instance.name
|
||||
@@ -467,7 +218,6 @@ Defaults to "New Instance".
|
||||
### instance.id
|
||||
|
||||
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaluts to a random 31 bit integer.
|
||||
|
||||
@@ -477,7 +227,6 @@ Defaluts to a random 31 bit integer.
|
||||
Host this instance is assigned to.
|
||||
To change this you need to use the separate assign instance interface.
|
||||
After being assigned to a host the directory for the instance is created on the host.
|
||||
Internal and hidden in the web interface.
|
||||
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
@@ -490,63 +239,26 @@ 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.
|
||||
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
|
||||
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
|
||||
Values that don't match one of these forms are rejected.
|
||||
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
|
||||
|
||||
Defaults to "latest".
|
||||
|
||||
|
||||
### factorio.executable_path
|
||||
|
||||
Path relative to the Factorio installation directory of the executable to run.
|
||||
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
|
||||
Only needed in special setups.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.shutdown_timeout
|
||||
|
||||
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
|
||||
Set to 0 to wait forever.
|
||||
Changing this while the instance is stopping restarts the timer with the new value.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 300.
|
||||
|
||||
|
||||
### factorio.game_port
|
||||
|
||||
UDP port to run the Factorio server on.
|
||||
If null a port from host.factorio_port_range on the host is assigned to the instance.
|
||||
Negative values are rejected.
|
||||
If null a random port in the dynamyc range assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.host_assigned_game_port
|
||||
|
||||
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
|
||||
Internal, set by the host and only accessible from the host.
|
||||
|
||||
|
||||
### factorio.rcon_port
|
||||
|
||||
TCP port to start the RCON interface on the Factorio server on.
|
||||
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||
Negative values are rejected.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
@@ -555,18 +267,16 @@ Defaults to null.
|
||||
|
||||
Password to use for the RCON interafec.
|
||||
If null a random secure password is generated and used each time the instance starts.
|
||||
The value is treated as a credential and can only be read back by the controller and the host.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.player_online_autosave_slots
|
||||
### player_online_autosave_slots
|
||||
|
||||
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
|
||||
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
|
||||
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
|
||||
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
|
||||
The same check is done when factorio.settings is changed.
|
||||
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
|
||||
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
|
||||
|
||||
If 0 no autosave renaming takes place.
|
||||
@@ -574,34 +284,12 @@ If 0 no autosave renaming takes place.
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### factorio.mod_pack_id
|
||||
|
||||
ID of the mod pack to use on this instance.
|
||||
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
|
||||
If null the mod pack set in controller.default_mod_pack_id is used.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.enable_save_patching
|
||||
|
||||
Whether to use save patching or not.
|
||||
When enabled lua code is patched into the game save before starting it.
|
||||
Most plugins require the use of save patching to function.
|
||||
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
|
||||
Plugins that need save patching can't be loaded on the instance while this is disabled.
|
||||
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_script_commands
|
||||
|
||||
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
|
||||
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
|
||||
When disabled attempts to send such a command fail with an error.
|
||||
This does not stop players from using these commands themselves.
|
||||
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
@@ -613,13 +301,6 @@ Turn on the whitelist on the server.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.enable_authserver_bans
|
||||
|
||||
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.settings
|
||||
|
||||
Object with the settings to put into `server-settings.json` for the Factorio server.
|
||||
@@ -627,7 +308,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
|
||||
Defaults to {"tags":["clusterio"],"auto_pause":false}.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
@@ -638,14 +319,6 @@ This prints more messages to the log.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.console_logging
|
||||
|
||||
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
|
||||
Useful for 3rd party tools that read the console log of the server.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.strip_paths
|
||||
|
||||
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
|
||||
@@ -668,35 +341,25 @@ Defaults to true.
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
|
||||
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to "bidirectional".
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_whitelist
|
||||
|
||||
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
|
||||
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
|
||||
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to "bidirectional".
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_banlist
|
||||
|
||||
Synchronize in-game banlist with the banned status of users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
|
||||
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to "bidirectional".
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
@@ -713,7 +376,6 @@ This means a high number of concurrent commands can amplify resends due to packe
|
||||
|
||||
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
|
||||
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
@@ -721,22 +383,21 @@ Defaults to 5.
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the given plugin on the instance.
|
||||
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
|
||||
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
|
||||
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Ctl Configuration
|
||||
## Control Configuration
|
||||
|
||||
### ctl.controller_url
|
||||
### control.controller_url
|
||||
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### ctl.controller_token
|
||||
### control.controller_token
|
||||
|
||||
Access token used for authenticating with the controller.
|
||||
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
|
||||
@@ -744,10 +405,17 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### ctl.max_reconnect_delay
|
||||
### control.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.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 60.
|
||||
+3
-10
@@ -77,7 +77,7 @@ The documentation uses the binaries provided by the packages, but for the develo
|
||||
|
||||
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
|
||||
|
||||
Note that to use `clusterioctl` you will have to create a ctl config first:
|
||||
Note that to use `clusterioctl` you will have to create a control config first:
|
||||
|
||||
node packages/controller bootstrap create-ctl-config [admin-user]
|
||||
|
||||
@@ -125,11 +125,6 @@ Please do add tests for any code you add and run both:
|
||||
|
||||
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
|
||||
|
||||
The full test suite can take a while to run.
|
||||
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
|
||||
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
|
||||
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
|
||||
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
@@ -142,8 +137,6 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
|
||||
After your feature branch have been merged you should consider it final.
|
||||
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
|
||||
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
@@ -229,8 +222,8 @@ Files containing and exporting a single class should be named the same as the cl
|
||||
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
||||
E.g.:
|
||||
|
||||
import libLink from "./link";
|
||||
import libLuaTools from "./lua_tools";
|
||||
const libLink = require("./link");
|
||||
const libLuaTools = require("./lua_tools");
|
||||
|
||||
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
|
||||
|
||||
|
||||
+7
-7
@@ -361,16 +361,16 @@ While we have considered alternatives since, transitioning to another solution w
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
🧾 7th October 2026
|
||||
🧾 Time Immemorial
|
||||
|
||||
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
|
||||
> All breaking changes are documented in the changelog along with migration actions.
|
||||
> We follow symver versioning for breaking changes.
|
||||
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
|
||||
> Once we enter beta, breaking changes will be more restricted.
|
||||
|
||||
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
|
||||
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
|
||||
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
|
||||
Changes to the plugin API may still be made in minor versions.
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
|
||||
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
|
||||
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
|
||||
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
|
||||
* A Data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
export class Data {
|
||||
class Data {
|
||||
/** @type {string} */
|
||||
member = "foo";
|
||||
|
||||
@@ -41,6 +41,8 @@ export class Data {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Data;
|
||||
```
|
||||
|
||||
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
|
||||
@@ -51,13 +53,14 @@ For example a class holding a data property with an instance of the Data class f
|
||||
|
||||
```js
|
||||
// Composed.js
|
||||
import Data from "./Data";
|
||||
"use strict";
|
||||
const Data = require("./Data");
|
||||
|
||||
/**
|
||||
* Composed data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
export class Composed {
|
||||
class Composed {
|
||||
/** @type {module:Data|undefined} */
|
||||
data;
|
||||
|
||||
@@ -80,6 +83,8 @@ export class Composed {
|
||||
|
||||
// toJSON is optional and not needed for this particular class
|
||||
}
|
||||
|
||||
module.exports = Composed;
|
||||
```
|
||||
|
||||
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
# Migrating Plugins to 2.0
|
||||
|
||||
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
|
||||
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
|
||||
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
|
||||
|
||||
|
||||
## Versioning from 2.0
|
||||
|
||||
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
|
||||
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
|
||||
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
|
||||
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
|
||||
|
||||
|
||||
## Convert to ES modules
|
||||
|
||||
Clusterio's packages are now ES modules and plugins should be too.
|
||||
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
||||
|
||||
Relative imports must include the full filename with the `.js` extension.
|
||||
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
||||
Importing a directory no longer resolves to its `index.js`.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const messages = require("./messages");
|
||||
|
||||
const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```js
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller.js",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
```
|
||||
|
||||
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
|
||||
|
||||
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
|
||||
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
|
||||
|
||||
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
|
||||
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
|
||||
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
|
||||
|
||||
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
|
||||
|
||||
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
|
||||
Converting the plugin avoids the problem.
|
||||
|
||||
|
||||
## Replace plugin classes with entrypoint functions
|
||||
|
||||
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
|
||||
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
|
||||
Class exports still load, but they are deprecated and a warning is logged for each one.
|
||||
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
|
||||
}
|
||||
|
||||
async onSaveData() {
|
||||
await this.saveFrobnicationData();
|
||||
}
|
||||
|
||||
async onInstanceStatusChanged(instance, prev) {
|
||||
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
}
|
||||
|
||||
async handleFrobnicate(request) { /* ... */ }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0 (JS)**
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
|
||||
controller.handle(Frobnicate, async (request) => { /* ... */ });
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
|
||||
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
|
||||
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**2.0 (TS)**
|
||||
```ts
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
// Same as above
|
||||
}
|
||||
```
|
||||
|
||||
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
|
||||
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
|
||||
|
||||
|
||||
### Keeping a class for state
|
||||
|
||||
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
|
||||
`fromContext` can then be the default export of the entrypoint.
|
||||
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
|
||||
|
||||
```ts
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
export class Frobber {
|
||||
private static instances = new WeakMap<Controller, Frobber>();
|
||||
private frobnications = new Map<string, number>();
|
||||
|
||||
private constructor(
|
||||
private controller: Controller,
|
||||
private logger: lib.Logger,
|
||||
) { }
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const frobber = new Frobber(context.controller, context.logger);
|
||||
await frobber.load();
|
||||
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
|
||||
Frobber.instances.set(context.controller, frobber);
|
||||
return frobber;
|
||||
}
|
||||
|
||||
static get(controller: Controller) {
|
||||
return Frobber.instances.get(controller);
|
||||
}
|
||||
|
||||
async load() { /* ... */ }
|
||||
async save() { /* ... */ }
|
||||
}
|
||||
|
||||
export default Frobber.fromContext;
|
||||
```
|
||||
|
||||
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
|
||||
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
|
||||
|
||||
|
||||
### Method to hook names
|
||||
|
||||
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
|
||||
The exceptions are:
|
||||
|
||||
- `onSaveData` on the controller is `controller.hooks.save`.
|
||||
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
|
||||
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
|
||||
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
|
||||
|
||||
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
|
||||
The ctl hooks are passed directly as `context.hooks`.
|
||||
|
||||
**Alpha 27**
|
||||
```jsx
|
||||
class WebPlugin extends BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```jsx
|
||||
export default async function(context) {
|
||||
const { control, plugin } = context;
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Replacing other class members
|
||||
|
||||
The class properties are on the context object passed to the entrypoint:
|
||||
|
||||
- `this.info` is `context.plugin`.
|
||||
- `this.logger` is `context.logger`.
|
||||
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
|
||||
- `this.metrics` on controller plugins is `context.metrics`.
|
||||
- `this.package` on web plugins is `context.package`.
|
||||
|
||||
The helper methods of the base classes have the following replacements:
|
||||
|
||||
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
|
||||
The plugin name is used to label the RCON metrics.
|
||||
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
|
||||
- `this.sendOrderedRcon(message)` has no replacement.
|
||||
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
|
||||
|
||||
|
||||
### Changes to hook behaviour
|
||||
|
||||
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
|
||||
Don't rely on another plugin's handler having finished before yours runs.
|
||||
|
||||
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
|
||||
Each hook accepts one handler per name, attach it under `plugin.name`.
|
||||
|
||||
|
||||
## Accessing other plugins
|
||||
|
||||
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
|
||||
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
|
||||
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
|
||||
|
||||
```js
|
||||
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
|
||||
const loaded = info !== undefined && controller.loadedPlugins.has(info);
|
||||
```
|
||||
|
||||
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
|
||||
`control.plugins` is deprecated and only holds plugins still using the class export.
|
||||
|
||||
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
|
||||
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
|
||||
|
||||
```ts
|
||||
import { Frobber } from "other_plugin/dist/node/controller.js";
|
||||
|
||||
const frobber = Frobber.get(controller);
|
||||
if (!frobber) {
|
||||
throw new Error("other_plugin is not loaded");
|
||||
}
|
||||
```
|
||||
|
||||
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
|
||||
|
||||
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
|
||||
The web plugin template created by `@clusterio/create` shows this for a subscriber.
|
||||
|
||||
|
||||
## Declare permissions on the plugin
|
||||
|
||||
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
|
||||
`lib.definePermission()` still works, but don't use both for the same permission name.
|
||||
|
||||
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
|
||||
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
|
||||
|
||||
```ts
|
||||
declare module "@clusterio/lib" {
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
|
||||
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
|
||||
|
||||
|
||||
## Renamed ctl classes and config
|
||||
|
||||
The remaining uses of "control" for clusterioctl were renamed to "ctl":
|
||||
|
||||
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
|
||||
Fields under the old name are silently ignored.
|
||||
- The `ControlConfigFields` interface is `CtlConfigFields`.
|
||||
- `lib.ControlConfig` is `lib.CtlConfig`.
|
||||
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
|
||||
|
||||
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
|
||||
|
||||
|
||||
## Static assets in the web interface
|
||||
|
||||
`staticRoot` now includes the `static/` prefix.
|
||||
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
|
||||
|
||||
|
||||
## Package dependencies
|
||||
|
||||
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
|
||||
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
|
||||
|
||||
```json
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "^2.0.0",
|
||||
"@clusterio/host": "^2.0.0",
|
||||
"@clusterio/lib": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -14,7 +14,7 @@ All messages sent and received are validated with JSON schemas to catch potentia
|
||||
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
|
||||
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
|
||||
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
|
||||
For ctl there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
|
||||
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
|
||||
|
||||
Messages received invokes message handlers that were registered when the link was brought up.
|
||||
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
|
||||
|
||||
@@ -5,8 +5,6 @@ The save patching is based on the built in [event_handler](https://github.com/wu
|
||||
Mods on the other hand work without modifications.
|
||||
|
||||
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
|
||||
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
|
||||
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
|
||||
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` s
|
||||
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
|
||||
|
||||
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
|
||||
|
||||
|
||||
## Hosts
|
||||
@@ -43,14 +43,6 @@ The controller also supports remote updates, following the same logic. The corre
|
||||
ctl> controller update [--restart]
|
||||
controller> config set controller.allow_remote_updates true/false
|
||||
|
||||
### Mark restart required
|
||||
|
||||
ctl> host mark-restart-required <host>
|
||||
|
||||
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
|
||||
|
||||
ctl> controller mark-restart-required
|
||||
|
||||
## Instances
|
||||
|
||||
### List Instances
|
||||
@@ -73,12 +65,6 @@ Terminates the running Factorio server without giving it a chance to save or cle
|
||||
|
||||
Note: This may cause loss of data.
|
||||
|
||||
### Save game
|
||||
|
||||
ctl> instance save-game <name>
|
||||
|
||||
Saves the running game of the instance to the save it was started from and waits for the save to finish.
|
||||
|
||||
### Extract players from instance
|
||||
|
||||
ctl> instance extract-players <name>
|
||||
@@ -255,13 +241,6 @@ This command lists all plugins currently known to the remote. Newly installed pl
|
||||
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Update All
|
||||
|
||||
ctl> controller update --all [--restart]
|
||||
ctl> host update <host> --all [--restart]
|
||||
|
||||
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
|
||||
|
||||
### Install
|
||||
|
||||
ctl> controller plugin install <name> [--restart]
|
||||
@@ -282,29 +261,3 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
|
||||
controller> config set controller.allow_plugin_install true/false
|
||||
|
||||
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
|
||||
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
|
||||
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
|
||||
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
|
||||
|
||||
Create a role with just this permission, a user to hold it, and generate a token for that user.
|
||||
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
|
||||
|
||||
ctl> role create Prometheus --permissions core.controller.metrics
|
||||
ctl> user create #prometheus
|
||||
ctl> user set-roles #prometheus Prometheus
|
||||
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
|
||||
|
||||
Then pass the token in the scrape job for the controller in `prometheus.yml`:
|
||||
|
||||
scrape_configs:
|
||||
- job_name: clusterio
|
||||
authorization:
|
||||
credentials: <token>
|
||||
static_configs:
|
||||
- targets: ["localhost:8080"]
|
||||
|
||||
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
|
||||
@@ -5,18 +5,10 @@
|
||||
- CHANGELOG.md in the repository root
|
||||
- The changelog.txt file in mod directories
|
||||
|
||||
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
|
||||
|
||||
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
|
||||
Pull requests will usually have suggestions for changelog entries.
|
||||
|
||||
## Packages
|
||||
|
||||
The [Publish workflow](../.github/workflows/publish.yml) publishes npm packages and Lua mods when a GitHub release is published.
|
||||
For this workflow, first merge the changelog and package version updates into `master`, then create and push the release tag on that merged commit.
|
||||
Create a draft GitHub release for the existing tag and use the GitHub Release Markdown from `scripts/pr_to_changelog.mts` as its notes.
|
||||
Publishing the draft triggers the workflow; pushing a tag alone does not.
|
||||
The steps below describe publishing npm packages manually instead.
|
||||
|
||||
> [!NOTE]
|
||||
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
||||
|
||||
@@ -46,7 +38,7 @@ The steps below describe publishing npm packages manually instead.
|
||||
|
||||
```sh
|
||||
pnpm clean
|
||||
pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish
|
||||
pnpm -r --filter='./{plugins,packages}/*' publish
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
+4
-19
@@ -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
|
||||
+103
-268
@@ -1,17 +1,15 @@
|
||||
# Writing Plugins
|
||||
|
||||
Plugins for Clusterio are JavaScript modules that run under Node.js.
|
||||
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
|
||||
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
|
||||
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Plugin Structure](#plugin-structure)
|
||||
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
|
||||
- [Defining the plugin class](#defining-the-plugin-class)
|
||||
- [Logging Messages](#logging-messages)
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Plugin Permissions](#plugin-permissions)
|
||||
- [Migrating Stored Data](#migrating-stored-data)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
@@ -29,7 +27,7 @@ The basic file structure of a plugin is the following.
|
||||
+- package.json
|
||||
+- controller.js
|
||||
+- instance.js
|
||||
+- ctl.js
|
||||
+- control.js
|
||||
+- test/
|
||||
| +- plugin.js
|
||||
+- module/
|
||||
@@ -50,12 +48,12 @@ By default the main entrypoint is the `index.js` file, but this may be changed b
|
||||
Here's an example of `index.js`:
|
||||
|
||||
```js
|
||||
export const plugin = {
|
||||
module.exports.plugin = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advanced frobnication",
|
||||
instanceEntrypoint: "instance.js",
|
||||
controllerEntrypoint: "controller.js",
|
||||
instanceEntrypoint: "instance",
|
||||
controllerEntrypoint: "controller",
|
||||
messages: {
|
||||
/* See below */
|
||||
},
|
||||
@@ -77,7 +75,7 @@ The following properties are recognized:
|
||||
Currently not used.
|
||||
|
||||
**instanceEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
|
||||
|
||||
@@ -86,7 +84,7 @@ The following properties are recognized:
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**controllerEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
|
||||
This is an optional parameter.
|
||||
A plugin can be made that only runs on the controller.
|
||||
|
||||
@@ -95,95 +93,77 @@ The following properties are recognized:
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**ctlEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin can be made that only runs on the clusterioctl side.
|
||||
|
||||
**migrationEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
||||
This is an optional parameter.
|
||||
See [Migrating Stored Data](#migrating-stored-data)
|
||||
|
||||
**features**:
|
||||
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
|
||||
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
||||
|
||||
**messages**:
|
||||
Object with link messages definitions for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
**permissions**:
|
||||
Array of permission definitions for this plugin.
|
||||
See [Plugin Permissions](#plugin-permissions)
|
||||
|
||||
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
|
||||
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
|
||||
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
|
||||
|
||||
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
|
||||
While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
|
||||
You can put them into whatever file you want (even the same one for both).
|
||||
|
||||
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
|
||||
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
|
||||
|
||||
|
||||
## Defining the plugin entrypoint
|
||||
## Defining the plugin class
|
||||
|
||||
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
|
||||
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
|
||||
For example, a controller entrypoint can look like this:
|
||||
The plugin class should derive from its respective base class defined in `lib/plugin`.
|
||||
For example, to define a ControllerPlugin class the following code can be used:
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
this.foo = 42;
|
||||
await this.startFrobnication();
|
||||
}
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("Stopping frobnication");
|
||||
});
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
}
|
||||
```
|
||||
|
||||
The context has the following properties:
|
||||
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
|
||||
The available hooks that you can override are documented in the base classes.
|
||||
|
||||
**plugin**:
|
||||
The plugin's own info, as exported from the main entrypoint.
|
||||
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
|
||||
E.g.:
|
||||
|
||||
**logger**:
|
||||
Logger for this plugin, see [Logging Messages](#logging-messages).
|
||||
```js
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
**controller**, **host**, **instance**, **hooks** or **control**:
|
||||
The object the entrypoint is loaded for.
|
||||
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
|
||||
// Code here
|
||||
}
|
||||
```
|
||||
|
||||
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
|
||||
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
|
||||
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
|
||||
|
||||
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
|
||||
|
||||
### Class based plugins
|
||||
|
||||
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
|
||||
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
|
||||
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
|
||||
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
|
||||
The async init method is always called immediatly after the constructor, so there's little reason to do this.
|
||||
|
||||
|
||||
## Logging Messages
|
||||
|
||||
The load context provides a winston logger for logging messages to the shared cluster log.
|
||||
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
|
||||
For instances a copy of the log is also stored on the host the instance is on.
|
||||
To use it, pass a string to one of the log levels functions, for example:
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
context.logger.info("Initializing frobbing");
|
||||
async init() {
|
||||
this.logger.info("Initializing frobbing");
|
||||
}
|
||||
```
|
||||
|
||||
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
|
||||
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
|
||||
|
||||
The available levels are fatal, error, warn, audit, info, and verbose.
|
||||
These levels have roughly the following meanings:
|
||||
@@ -209,7 +189,7 @@ For guarding unexpected errors the best option is to log a short description alo
|
||||
try {
|
||||
// Operation that should not throw but may end up throwing
|
||||
} catch (err) {
|
||||
logger.error(`Operation failed:\n${err.stack}`);
|
||||
this.logger.error(`Operation failed:\n${err.stack}`);
|
||||
)
|
||||
```
|
||||
|
||||
@@ -233,7 +213,7 @@ declare module "@clusterio/lib" {
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin = {
|
||||
export default {
|
||||
...
|
||||
controllerConfigFields: {
|
||||
"foo_frobber.level": {
|
||||
@@ -245,12 +225,12 @@ export const plugin = {
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
|
||||
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
|
||||
|
||||
```ts
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
const level = context.controller.config.get("foo_frobber.level");
|
||||
context.logger.info(`I got a frobnication level of ${level}`);
|
||||
async init() {
|
||||
let level = this.controller.config.get("foo_frobber.level");
|
||||
this.logger.info(`I got a frobnication level of ${level}`);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -260,144 +240,33 @@ See [Configuration System](config-system.md) for more details on how this system
|
||||
|
||||
### Handling Invalid Configuration
|
||||
|
||||
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
|
||||
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
|
||||
For example:
|
||||
|
||||
```js
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
// ...
|
||||
features: ["SavePatching"],
|
||||
};
|
||||
```
|
||||
|
||||
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
|
||||
|
||||
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
|
||||
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
|
||||
See [Configuration System](config-system.md) for details.
|
||||
|
||||
|
||||
## Plugin Permissions
|
||||
|
||||
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
|
||||
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
|
||||
Names must start with the plugin name followed by a dot.
|
||||
For example in index.ts:
|
||||
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
// Extend the interface of known permission names so that
|
||||
// user.checkPermission(...) accepts our permission.
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("foo_frobber plugin requires save patching.");
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin = {
|
||||
...
|
||||
permissions: [
|
||||
{
|
||||
name: "foo_frobber.frobnicate",
|
||||
title: "Frobnicate",
|
||||
description: "Run frobnication on instances.",
|
||||
grantByDefault: false, // Whether the generated Player role is granted this permission
|
||||
},
|
||||
],
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
|
||||
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
|
||||
In JavaScript the `declare module` block is not needed.
|
||||
|
||||
Permissions declared this way are registered when the plugin is loaded.
|
||||
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
|
||||
|
||||
|
||||
## Migrating Stored Data
|
||||
|
||||
Clusterio stores the config fields and permissions of plugins, so a plugin that renames them needs to migrate the stored values or users lose what they had set.
|
||||
This is done from the migration entrypoint, which is loaded on the controller and hosts before any config or role is read, for all plugins in the plugin list including those disabled with `load_plugin`.
|
||||
It is passed a context with `hooks` and attaches handlers for the data it needs to migrate:
|
||||
|
||||
```js
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export default function(context) {
|
||||
const { hooks, plugin } = context;
|
||||
// Plugin was renamed from frobber in 2.0.0
|
||||
const renameFields = config => lib.renameConfigPrefix(config, "frobber.", "foo_frobber.");
|
||||
hooks.controllerConfig.attach(plugin.name, renameFields);
|
||||
hooks.hostConfig.attach(plugin.name, renameFields);
|
||||
hooks.instanceConfig.attach(plugin.name, renameFields);
|
||||
hooks.role.attach(plugin.name, role => lib.renamePermissionPrefix(role, "frobber.", "foo_frobber."));
|
||||
}
|
||||
```
|
||||
|
||||
The handlers get the serialized config or role after the core migrations and return the migrated version.
|
||||
They run synchronously, a handler that throws stops the controller or host from starting.
|
||||
`lib.renameConfigField`, `lib.renameConfigPrefix`, `lib.renamePermission` and `lib.renamePermissionPrefix` cover the common case of renaming.
|
||||
|
||||
Handlers run every time the data is loaded and must leave data that is already migrated unchanged.
|
||||
For changes that can't be repeated, each config has a hidden `<plugin>.version` field with the version of the plugin it was last loaded with, which is updated after the migrations have run:
|
||||
|
||||
```js
|
||||
hooks.instanceConfig.attach(plugin.name, config => {
|
||||
const version = config["foo_frobber.version"];
|
||||
// The default delay went from 1 to 2 in 1.2.0, update configs still on the old default
|
||||
if (
|
||||
version && lib.integerFullVersion(version) < lib.integerFullVersion("1.2.0")
|
||||
&& config["foo_frobber.delay"] === 1
|
||||
) {
|
||||
config["foo_frobber.delay"] = 2;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
The version field is missing or empty in configs saved before Clusterio tracked it, and in configs last loaded in recovery mode.
|
||||
|
||||
Roles have no version of their own.
|
||||
They are stored on the controller, so use the version in the controller config, which is loaded before the roles:
|
||||
|
||||
```js
|
||||
let controllerVersion;
|
||||
hooks.controllerConfig.attach(plugin.name, config => {
|
||||
controllerVersion ??= config["foo_frobber.version"] ?? "";
|
||||
return config;
|
||||
});
|
||||
hooks.role.attach(plugin.name, role => {
|
||||
// foo_frobber.edit was split out of foo_frobber.view in 1.2.0
|
||||
if (
|
||||
controllerVersion && lib.integerFullVersion(controllerVersion) < lib.integerFullVersion("1.2.0")
|
||||
&& role.permissions.includes("foo_frobber.view")
|
||||
) {
|
||||
role.permissions.push("foo_frobber.edit");
|
||||
}
|
||||
return role;
|
||||
});
|
||||
```
|
||||
|
||||
Data the plugin stores itself, like files in the database directory or values in the save, is up to the plugin to migrate from its other entrypoints.
|
||||
Modules don't need migrating, save patching replaces the files of every module when an instance starts.
|
||||
|
||||
|
||||
## Communicating with Factorio
|
||||
|
||||
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
|
||||
This is done by calling the `sendRcon` method on the instance.
|
||||
This is done by calling the `sendRcon` method on the plugin object.
|
||||
For example:
|
||||
|
||||
```js
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
const response = await instance.sendRcon(
|
||||
async onStart() {
|
||||
let response = await this.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
// Do stuff with response.
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
|
||||
@@ -410,16 +279,15 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
|
||||
For example in the plugin code:
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { instance, logger } = context;
|
||||
instance.server.on("ipc-my_plugin_foo", content =>
|
||||
handleFoo(content).catch(err => logger.error(
|
||||
async init() {
|
||||
this.instance.server.on("ipc-my_plugin_foo", content =>
|
||||
this.handleFoo(content).catch(err => this.logger.error(
|
||||
`Error handling foo:\n${err.stack}`
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async function handleFoo(content) {
|
||||
async handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
@@ -436,11 +304,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
|
||||
For convience a helper function is exposed on server specifically for handling IPC events.
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
context.instance.server.handle("my_plugin_foo", handleFoo);
|
||||
async init() {
|
||||
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
|
||||
}
|
||||
|
||||
async function handleFoo(content) {
|
||||
async handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
@@ -577,34 +445,15 @@ Link messages are sent by calling the `.sendTo()` method on a connection object
|
||||
For example:
|
||||
|
||||
```js
|
||||
// In an instance entrypoint
|
||||
async function frobnicate(instance) {
|
||||
const response = await instance.sendTo("controller", new messages.Frobnicate({
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
|
||||
verbosity: 2,
|
||||
special: false,
|
||||
}));
|
||||
console.log(response); // { report: [...] }
|
||||
}
|
||||
```
|
||||
Because the send and handle methods rely on this, you should always call them directly on the connection object.
|
||||
|
||||
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
|
||||
|
||||
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
|
||||
|
||||
Here is what that looks like in practice:
|
||||
|
||||
```js
|
||||
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
|
||||
const send = instance.sendTo;
|
||||
send("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
|
||||
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
|
||||
```
|
||||
|
||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
||||
@@ -625,23 +474,23 @@ sendTo({ controlId: 123 }, message);
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
The most important is the prepare disconnect for the link between controller and host.
|
||||
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
|
||||
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
|
||||
|
||||
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
||||
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
|
||||
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
|
||||
```js
|
||||
if (host.connected) {
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
if (this.host.connected) {
|
||||
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
}
|
||||
```
|
||||
|
||||
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
|
||||
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
|
||||
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
|
||||
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
|
||||
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
|
||||
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
||||
@@ -657,7 +506,7 @@ In its simplest form collecting data from plugins consists of defining the metri
|
||||
For example:
|
||||
|
||||
```js
|
||||
import { Counter } from "@clusterio/lib";
|
||||
const { Counter } = require("@clusterio/lib");
|
||||
|
||||
const fooMetric = new Counter(
|
||||
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
|
||||
@@ -667,13 +516,13 @@ const fooMetric = new Counter(
|
||||
fooMetric.inc();
|
||||
```
|
||||
|
||||
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
|
||||
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
|
||||
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
|
||||
|
||||
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
||||
|
||||
```js
|
||||
import { Counter } from "@clusterio/lib";
|
||||
const { Counter } = require("@clusterio/lib");
|
||||
|
||||
const barMetric = new Gauge(
|
||||
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
||||
@@ -681,48 +530,23 @@ const barMetric = new Gauge(
|
||||
);
|
||||
|
||||
// Somewhere in the instance plugin code
|
||||
barMetric.labels(String(instance.id)).set(someValue);
|
||||
barMetric.labels(String(this.instance.id)).set(someValue);
|
||||
```
|
||||
|
||||
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
|
||||
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
|
||||
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
|
||||
|
||||
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
|
||||
|
||||
```js
|
||||
import { Gauge } from "@clusterio/lib";
|
||||
|
||||
const frobnicationsMetric = new Gauge(
|
||||
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
|
||||
);
|
||||
|
||||
// In the controller entrypoint
|
||||
controller.hooks.metrics.attach(plugin.name, async () => {
|
||||
frobnicationsMetric.set(frobnications.size);
|
||||
});
|
||||
```
|
||||
|
||||
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
|
||||
|
||||
```js
|
||||
import { CollectorRegistry, Counter } from "@clusterio/lib";
|
||||
|
||||
const fooRegistry = new CollectorRegistry();
|
||||
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
|
||||
fooRegistry.register(fooCounter);
|
||||
|
||||
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
|
||||
```
|
||||
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
|
||||
|
||||
|
||||
## Adding Custom Commands to clusterioctl
|
||||
|
||||
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The control entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The creation of custom commands typically starts with defining a command tree for the plugin:
|
||||
|
||||
```js
|
||||
import { Command, CommandTree } from "@clusterio/lib";
|
||||
const { Command, CommandTree } = require("@clusterio/lib");
|
||||
const fooFrobberCommands = new CommandTree({
|
||||
name: "foo-frobber", description: "Foo Frobber Plugin commands"
|
||||
});
|
||||
@@ -731,30 +555,41 @@ const fooFrobberCommands = new CommandTree({
|
||||
Then commands are added to the the plugin's command tree:
|
||||
|
||||
```js
|
||||
import * as messages from "./messages.js";
|
||||
const info = require("./info");
|
||||
|
||||
fooFrobberCommands.add(new Command({
|
||||
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
|
||||
yargs.positional("type", {
|
||||
yargs.positional("level", {
|
||||
describe: "type of frobnication", type: "string"
|
||||
});
|
||||
}],
|
||||
handler: async function(args, ctl) {
|
||||
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
|
||||
handler: async function(args, control) {
|
||||
await info.messages.frobnicate.send(control, {
|
||||
instance_name: "Console",
|
||||
content: args.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
|
||||
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
|
||||
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
|
||||
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
|
||||
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
|
||||
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
|
||||
class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
rootCommand.add(fooFrobberCommands);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
}
|
||||
```
|
||||
+31
-36
@@ -2,34 +2,32 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
|
||||
"type": "module",
|
||||
"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",
|
||||
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
|
||||
"cover": "c8 pnpm test",
|
||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
"fast-cover": "FAST_TEST=y nyc pnpm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"clean": "node ./clean.js",
|
||||
"clean": "node ./clean.js fast",
|
||||
"clean-tests": "node ./clean.js tests",
|
||||
"clean-all": "node ./clean.js all",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"c8": {
|
||||
"nyc": {
|
||||
"exclude": [
|
||||
"test/**",
|
||||
"plugins/*/test/**",
|
||||
"temp/**"
|
||||
"plugins/*/test/**"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -38,29 +36,26 @@
|
||||
"external_plugins/*/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/ctl": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"eslint": "^10.10.0",
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@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": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"globals": "^17.12.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"typedoc": "^0.28.20",
|
||||
"typescript": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
"express": "^4.21.2",
|
||||
"form-data": "^4.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.7.3",
|
||||
"yargs": "^17.7.2"
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,9 @@ Common options:
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for the controller.
|
||||
Defaults to `config-controller.json` and is required for operation.
|
||||
Defaults to `config-controller.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
|
||||
Alternative to the `config create` command.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
@@ -28,28 +25,17 @@ Configure plugins available to be loaded by the controller.
|
||||
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
|
||||
|
||||
|
||||
#### `plugin install <package>`
|
||||
|
||||
Install a plugin from npm and add it to the plugin list.
|
||||
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
|
||||
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
|
||||
|
||||
For example, adding a plugin checked out in the external_plugins directory:
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusteriocontroller plugin add ./external_plugins/my_plugin
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
@@ -77,13 +63,6 @@ Manage the controller configuration offline.
|
||||
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
|
||||
|
||||
|
||||
#### `config create`
|
||||
|
||||
Create a new config file with the default options inserted into if it doesn't already exist.
|
||||
|
||||
See also the `--create-config` option.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
@@ -125,16 +104,6 @@ This can be used both in the web interface and as the `controller_token` to conn
|
||||
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
|
||||
|
||||
|
||||
### `copy-static [target]`
|
||||
|
||||
Copies the static web assets to the given target folder (which defaults to `./static/`).
|
||||
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
|
||||
|
||||
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
|
||||
|
||||
See also the `controller.public_url` config option for setting where static assets are loaded from.
|
||||
|
||||
|
||||
### `run`
|
||||
|
||||
Runs the controller.
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "node:fs/promises";
|
||||
import fs from "fs-extra";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
import yargs from "yargs";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
@@ -27,12 +28,11 @@ import jwt from "jsonwebtoken";
|
||||
|
||||
// homebrew modules
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
const { ConsoleTransport, levels, logger } = lib;
|
||||
|
||||
import Controller from "./src/Controller.js";
|
||||
import UserManager from "./src/UserManager.js";
|
||||
import UserRecord from "./src/UserRecord.js";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
import Controller from "./src/Controller";
|
||||
import UserManager from "./src/UserManager";
|
||||
import { version } from "./package.json";
|
||||
|
||||
// globals
|
||||
let controller: Controller;
|
||||
@@ -101,9 +101,6 @@ void new lib.Gauge(
|
||||
"clusterio_controller_websocket_active_connections",
|
||||
"How many WebSocket connections are currently open to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.set(controller.wsServer.activeConnectors.size);
|
||||
}}
|
||||
);
|
||||
@@ -112,9 +109,6 @@ void new lib.Gauge(
|
||||
"clusterio_controller_active_hosts",
|
||||
"How many hosts are currently connected to the controller",
|
||||
{ callback: function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.set(controller.wsServer.hostConnections.size);
|
||||
}}
|
||||
);
|
||||
@@ -124,9 +118,6 @@ void new lib.Gauge(
|
||||
"How many clients are currently connected to this controller",
|
||||
{
|
||||
labels: ["type"], callback: async function(gauge:lib.Gauge) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.labels("host").set(controller.wsServer.hostConnections.size);
|
||||
gauge.labels("control").set(controller.wsServer.controlConnections.size);
|
||||
},
|
||||
@@ -136,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);
|
||||
@@ -190,8 +171,7 @@ async function handleBootstrapCommand(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
@@ -201,59 +181,25 @@ async function handleBootstrapCommand(
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let ctlConfig = new lib.CtlConfig("control");
|
||||
let controlConfig = new lib.ControlConfig("control");
|
||||
|
||||
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
ctlConfig.set(
|
||||
"ctl.controller_token",
|
||||
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
controlConfig.set(
|
||||
"control.controller_token",
|
||||
userManager.signUserToken(admin),
|
||||
);
|
||||
|
||||
let content = JSON.stringify(ctlConfig, null, "\t");
|
||||
let content = JSON.stringify(controlConfig, null, "\t");
|
||||
if (args.output === "-") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
|
||||
await lib.safeOutputFile(args.output, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyStaticCommand(
|
||||
args: any,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
) {
|
||||
await fs.cp(
|
||||
path.join(import.meta.dirname, "..", "web", "static"),
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
logger.info("Copied main web interface files");
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
try {
|
||||
await fs.cp(
|
||||
pluginInfo.webStaticPath,
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
|
||||
} else {
|
||||
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
logger.info(`Copied web interface files for ${pluginInfo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ControllerArgs {
|
||||
[x: string]: unknown;
|
||||
@@ -277,7 +223,7 @@ interface InitializeParameters {
|
||||
|
||||
async function initialize(): Promise<InitializeParameters> {
|
||||
// argument parsing
|
||||
const args = await yargs(process.argv.slice(2))
|
||||
const args = await yargs
|
||||
.scriptName("controller")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
@@ -299,19 +245,12 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
default: "config-controller.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("create-config", {
|
||||
nargs: 0,
|
||||
describe: "Create config file if it does not exist",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
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 => {
|
||||
@@ -328,18 +267,11 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
|
||||
yargs.option("output", {
|
||||
describe: "Path to output config (- for stdout)", type: "string",
|
||||
nargs: 1, default: "config-ctl.json",
|
||||
nargs: 1, default: "config-control.json",
|
||||
});
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run");
|
||||
})
|
||||
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
|
||||
yargs.positional("target", {
|
||||
describe: "Target directory to copy files to",
|
||||
type: "string",
|
||||
default: "static",
|
||||
});
|
||||
})
|
||||
.command("run", "Run controller", yargs => {
|
||||
yargs.option("can-restart", {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
@@ -349,13 +281,12 @@ 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" });
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.parse()
|
||||
.argv
|
||||
;
|
||||
|
||||
// Combined log stream of the whole cluster.
|
||||
@@ -392,7 +323,7 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
let command = args._[0];
|
||||
let shouldRun = false;
|
||||
if (command === "run") {
|
||||
logger.info(`Starting Clusterio controller ${packageConfig.version}`);
|
||||
logger.info(`Starting Clusterio controller ${version}`);
|
||||
if (args.recovery) {
|
||||
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
|
||||
}
|
||||
@@ -417,19 +348,25 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
if (!args.recovery) {
|
||||
await lib.loadPluginMigrations(pluginInfos);
|
||||
|
||||
let controllerConfig;
|
||||
const controllerConfigPath = args.config;
|
||||
logger.info(`Loading config from ${controllerConfigPath}`);
|
||||
try {
|
||||
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info("Config not found, initializing new config");
|
||||
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
|
||||
args,
|
||||
lib.ControllerConfig,
|
||||
"controller",
|
||||
);
|
||||
|
||||
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
|
||||
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
|
||||
|
||||
if (!controllerConfig.get("controller.auth_secret")) {
|
||||
logger.info("Generating new controller authentication secret");
|
||||
@@ -440,13 +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 (command === "copy-static") {
|
||||
await handleCopyStaticCommand(args, pluginInfos);
|
||||
} else if (shouldRun) {
|
||||
await controllerConfigLock.acquire(); // Hold the lock until process exit
|
||||
await handleBootstrapCommand(args, controllerConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -483,17 +417,33 @@ 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);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
controller.stop();
|
||||
@@ -503,8 +453,16 @@ async function startup() {
|
||||
}
|
||||
|
||||
export function bootstrap() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startup().catch(err => {
|
||||
if (err instanceof lib.StartupError || err.code === "InstallationError") {
|
||||
if (err instanceof lib.StartupError) {
|
||||
logger.fatal(`
|
||||
+-------------------------------+
|
||||
| Unable to to start controller |
|
||||
@@ -532,6 +490,6 @@ ${err.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { bootstrap } from "./controller.js";
|
||||
export { default as Controller } from "./src/Controller.js";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter.js";
|
||||
export { default as ControlConnection } from "./src/ControlConnection.js";
|
||||
export { default as HostConnection } from "./src/HostConnection.js";
|
||||
export { default as InstanceRecord } from "./src/InstanceRecord.js";
|
||||
export { default as InstanceManager } from "./src/InstanceManager.js";
|
||||
export { default as HostRecord } from "./src/HostRecord.js";
|
||||
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
|
||||
export { default as UserManager } from "./src/UserManager.js";
|
||||
export { default as UserRecord } from "./src/UserRecord.js";
|
||||
export { default as User } from "./src/User.js";
|
||||
export { default as WsServer } from "./src/WsServer.js";
|
||||
import { bootstrap } from "./controller";
|
||||
export { default as Controller } from "./src/Controller";
|
||||
export { default as ControllerUser } from "./src/ControllerUser";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter";
|
||||
export { default as ControlConnection } from "./src/ControlConnection";
|
||||
export { default as HostConnection } from "./src/HostConnection";
|
||||
export { default as InstanceInfo } from "./src/InstanceInfo";
|
||||
export { default as HostInfo } from "./src/HostInfo";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
export { default as WsServer } from "./src/WsServer";
|
||||
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,74 +1,63 @@
|
||||
{
|
||||
"name": "@clusterio/controller",
|
||||
"version": "2.0.1",
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"version": "2.0.0-alpha.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": {
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.2",
|
||||
"express": "catalog:",
|
||||
"finalhandler": "^2.1.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
"compression": "^1.8.0",
|
||||
"express": "^4.21.2",
|
||||
"finalhandler": "^1.3.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.18.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-dev-middleware": "^6.1.3",
|
||||
"webpack-merge": "catalog:"
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/finalhandler": "^1.2.0",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/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"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import path from "path";
|
||||
import fs from "node:fs/promises";
|
||||
import fs from "fs-extra";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import * as routes from "./routes.js";
|
||||
const { logger } = lib;
|
||||
import * as routes from "./routes";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ import * as routes from "./routes.js";
|
||||
* @alias module:controller/src/BaseConnection
|
||||
*/
|
||||
export default class BaseConnection extends lib.Link {
|
||||
declare "connector": WsServerConnector;
|
||||
declare ["connector"]: WsServerConnector;
|
||||
|
||||
constructor(
|
||||
connector: lib.WebSocketBaseConnector,
|
||||
@@ -32,8 +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.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
@@ -80,7 +78,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: lib.SourceVersion, 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) {
|
||||
@@ -95,12 +93,10 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
|
||||
let mod = this.getMod(request);
|
||||
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
|
||||
// Open source file before creating the stream in case it throws.
|
||||
let source = (await fs.open(modPath)).createReadStream();
|
||||
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = mod.filename;
|
||||
stream.source = source;
|
||||
stream.source = fs.createReadStream(modPath);
|
||||
stream.mime = "application/zip";
|
||||
stream.size = String(mod.size);
|
||||
|
||||
@@ -110,12 +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);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,45 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import type ControlConnection from "./ControlConnection.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||
controller: Controller;
|
||||
metrics: any;
|
||||
}>;
|
||||
import type {
|
||||
CollectorResult, Event, InstanceStatus, Logger,
|
||||
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
|
||||
} from "@clusterio/lib";
|
||||
import type Controller from "./Controller";
|
||||
import type InstanceInfo from "./InstanceInfo";
|
||||
import type ControlConnection from "./ControlConnection";
|
||||
import type HostConnection from "./HostConnection";
|
||||
|
||||
/**
|
||||
* Collection of controller plugin hooks
|
||||
* Base class for controller plugins
|
||||
*
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
*/
|
||||
export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger: lib.Logger) {
|
||||
super(logger);
|
||||
this.save = this.newHook();
|
||||
this.metrics = this.newHook();
|
||||
this.shutdown = this.newHook();
|
||||
this.instanceStatusChanged = this.newHook();
|
||||
this.instanceConfigFieldChanged = this.newHook();
|
||||
this.controllerConfigFieldChanged = this.newHook();
|
||||
this.controlConnectionEvent = this.newHook();
|
||||
this.hostConnectionEvent = this.newHook();
|
||||
this.prepareHostDisconnect = this.newHook();
|
||||
this.modPacksUpdated = this.newHook();
|
||||
this.modsUpdated = this.newHook();
|
||||
this.rolesUpdated = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
export default class BaseControllerPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
public info: PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
logger: Logger
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called when the controller saves data in memory to disk
|
||||
*
|
||||
@@ -41,7 +51,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* BaseControllerPlugin.onShutdown} have been invoked and all links have
|
||||
* been disconnected.
|
||||
*/
|
||||
readonly save: lib.AsyncHook<[]>;
|
||||
async onSaveData() { }
|
||||
|
||||
/**
|
||||
* Called when the status of an instance changes
|
||||
@@ -81,7 +91,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The instance that changed.
|
||||
* @param prev - the previous status of the instance.
|
||||
*/
|
||||
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
|
||||
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
|
||||
|
||||
/**
|
||||
* Called when the value of a controller config field changed.
|
||||
@@ -93,7 +103,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
|
||||
|
||||
/**
|
||||
* Called when the value of an instance config field changed.
|
||||
@@ -107,8 +117,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
@@ -125,12 +134,12 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
*
|
||||
* @returns an async iterator of prometheus metric results or undefined.
|
||||
*/
|
||||
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
|
||||
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
|
||||
|
||||
/**
|
||||
* Called when the controller is shutting down
|
||||
*/
|
||||
readonly shutdown: lib.AsyncHook<[]>;
|
||||
async onShutdown() { }
|
||||
|
||||
/**
|
||||
* Called when an event on a host connection happens
|
||||
@@ -165,8 +174,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume and close
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
|
||||
/**
|
||||
* Called when an avent on a control connection happens
|
||||
@@ -202,8 +210,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume, and close.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
|
||||
|
||||
/**
|
||||
* Called when a host is preparing to disconnect from the controller
|
||||
@@ -217,7 +224,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param connection -
|
||||
* The connection to the host preparing to disconnect.
|
||||
*/
|
||||
readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
|
||||
async onPrepareHostDisconnect(connection: HostConnection) { }
|
||||
|
||||
/**
|
||||
* Called when one or more mod packs are updated
|
||||
@@ -230,7 +237,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
*
|
||||
* @param modPacks - Mod packs that updated.
|
||||
*/
|
||||
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
|
||||
async onModPacksUpdated(modPacks: ModPack[]) { }
|
||||
|
||||
/**
|
||||
* Called when one or more mod stored on the controller are updated
|
||||
@@ -242,18 +249,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
*
|
||||
* @param mods - Mods that updated.
|
||||
*/
|
||||
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
|
||||
|
||||
/**
|
||||
* Called when one or more roles stored on the controller are updated
|
||||
*
|
||||
* Invoked when one or more roles have been added, updated or deleted.
|
||||
*
|
||||
* If a role has been deleted its `.isDeleted` property will be true.
|
||||
*
|
||||
* @param roles - Roles that updated.
|
||||
*/
|
||||
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
|
||||
async onModsUpdated(mods: ModInfo[]) { }
|
||||
|
||||
/**
|
||||
* Called when a player joins or leaves an instance
|
||||
@@ -265,79 +261,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The instance it occured on.
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface BaseControllerPlugin {
|
||||
onSaveData?(): Promise<void>;
|
||||
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
|
||||
onShutdown?(): Promise<void>;
|
||||
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
|
||||
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
|
||||
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
|
||||
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
|
||||
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
|
||||
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for controller plugins
|
||||
*
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class BaseControllerPlugin {
|
||||
constructor(
|
||||
public info: lib.PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
public logger: lib.Logger,
|
||||
) {
|
||||
const attach = <Args extends unknown[], Return>(
|
||||
hook: lib.AsyncHook<Args, Return>,
|
||||
fn?: lib.HookHandler<Args, Return>,
|
||||
) => {
|
||||
if (fn) {
|
||||
hook.attach(info.name, fn.bind(this));
|
||||
}
|
||||
};
|
||||
|
||||
attach(controller.hooks.save, this.onSaveData);
|
||||
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
|
||||
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
|
||||
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
|
||||
attach(controller.hooks.metrics, this.onMetrics);
|
||||
attach(controller.hooks.shutdown, this.onShutdown);
|
||||
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
|
||||
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
|
||||
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
|
||||
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
|
||||
attach(controller.hooks.modsUpdated, this.onModsUpdated);
|
||||
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
|
||||
attach(controller.hooks.playerEvent, this.onPlayerEvent);
|
||||
}
|
||||
|
||||
static fromContext(context: ControllerPluginContext) {
|
||||
return new this(context.plugin, context.controller, context.metrics, context.logger);
|
||||
}
|
||||
|
||||
detachHooks() {
|
||||
this.controller.hooks.detachAll(this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() {}
|
||||
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
|
||||
|
||||
/**
|
||||
* Broadcast event to all connected hosts
|
||||
@@ -348,7 +272,7 @@ export class BaseControllerPlugin {
|
||||
*
|
||||
* @param event - Event to send
|
||||
*/
|
||||
broadcastEventToHosts<T>(event: lib.Event<T>) {
|
||||
broadcastEventToHosts<T>(event: Event<T>) {
|
||||
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
|
||||
if (
|
||||
!hostConnection.connector.closing
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import events from "events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logFilter, logger } from "@clusterio/lib";
|
||||
const { logFilter, logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import * as routes from "./routes.js";
|
||||
import Controller from "./Controller.js";
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import ControllerUser from "./ControllerUser";
|
||||
import * as routes from "./routes";
|
||||
import Controller from "./Controller";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -41,23 +40,19 @@ 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);
|
||||
|
||||
this._version = registerData.version;
|
||||
|
||||
// A resume installs a fresh socket, so re-apply the ignore flag or the
|
||||
// dumper's own frames get echoed back into it and recurse forever.
|
||||
for (let event of ["connect", "resume"] as const) {
|
||||
this.connector.on(event, () => {
|
||||
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
}
|
||||
this.connector.on("connect", () => {
|
||||
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
|
||||
});
|
||||
this.connector.on("close", () => {
|
||||
if (this.logTransport) {
|
||||
this._controller.clusterLogger.remove(this.logTransport);
|
||||
logger.remove(this.logTransport);
|
||||
this.logTransport = null;
|
||||
}
|
||||
if (this.ws_dumper) {
|
||||
@@ -67,18 +62,16 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
this.connector.on(event, () => {
|
||||
this._controller.hooks.controlConnectionEvent.invoke(this, event);
|
||||
for (let controllerPlugin of this._controller.plugins.values()) {
|
||||
controllerPlugin.onControlConnectionEvent(this, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
|
||||
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
|
||||
this.handle(
|
||||
lib.ControllerMarkRestartRequiredRequest, this.handleControllerMarkRestartRequiredRequest.bind(this)
|
||||
);
|
||||
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
@@ -90,7 +83,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
@@ -108,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));
|
||||
@@ -133,8 +123,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -175,7 +165,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
if (typeof permission === "string") {
|
||||
this.user.checkPermission(permission as lib.PermissionName);
|
||||
this.user.checkPermission(permission);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,15 +185,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!this._controller.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
}
|
||||
const downgrade = await this._controller.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
||||
`is older than running version ${runningVersion}. ` +
|
||||
"Stop the controller before starting the older version manually."
|
||||
);
|
||||
}
|
||||
this._controller.shouldRestart = true;
|
||||
this._controller.stop();
|
||||
}
|
||||
@@ -212,26 +193,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
return this._controller.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this._controller.config.stageProp(
|
||||
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.config.commitStaging();
|
||||
} finally {
|
||||
this._controller.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
|
||||
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
|
||||
}
|
||||
@@ -287,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() {
|
||||
@@ -295,9 +256,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||
if ((request.config["instance.assigned_host"] ?? null) !== null) {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
if (request.cloneFromId) {
|
||||
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
||||
@@ -308,69 +266,42 @@ 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");
|
||||
}
|
||||
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (fieldName === "instance.id") {
|
||||
if (request.field === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
ControlConnection.validateInstanceConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
instance.config.stageProp(
|
||||
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
instance.config.commitStaging();
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
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;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
|
||||
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
|
||||
await this._controller.instanceAssign(request.instanceId, request.hostId);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsListRequest() {
|
||||
@@ -404,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) {
|
||||
@@ -633,232 +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 recommendedRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const optionalRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const candidateReleases = new Map<string, lib.ModInfo>();
|
||||
const errors = new Map<string, lib.ModDependencyResolveErrors>();
|
||||
|
||||
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 for the mod pack's Factorio version
|
||||
const release = releases.releases
|
||||
.filter(info => (
|
||||
versionRange.testVersion(info.version)
|
||||
&& lib.normaliseMajorMinorVersion(
|
||||
lib.normaliseSourceVersion(info.info_json.factorio_version)
|
||||
) === factorioVersion
|
||||
))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(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 (dependencies can be promoted to a stricter requirement)
|
||||
const versionRange =
|
||||
dependencyRequirements.get(mod.name)
|
||||
?? recommendedRequirements.get(mod.name)
|
||||
?? optionalRequirements.get(mod.name)
|
||||
?? new lib.ModVersionRange();
|
||||
|
||||
if (mod.required) {
|
||||
// Add to required, remove from recommended and optional
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
recommendedRequirements.delete(mod.name);
|
||||
optionalRequirements.delete(mod.name);
|
||||
|
||||
} else if (mod.recommended) {
|
||||
// If not required, add to recommended and remove from optional
|
||||
if (!dependencyRequirements.has(mod.name)) {
|
||||
recommendedRequirements.set(mod.name, versionRange);
|
||||
optionalRequirements.delete(mod.name);
|
||||
}
|
||||
|
||||
} else if (
|
||||
!dependencyRequirements.has(mod.name)
|
||||
&& !recommendedRequirements.has(mod.name)
|
||||
) {
|
||||
// If not required or recommended, add to optional
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
// Update the version range if needed
|
||||
if (mod.version) {
|
||||
versionRange.combineVersion(mod.version);
|
||||
if (!versionRange.valid) {
|
||||
candidateReleases.delete(mod.name);
|
||||
errors.set(mod.name, "unsatisfiable");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
&& info.factorioVersion === factorioVersion
|
||||
&& 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 and unsatisfiable dependencies if they aren't required by others
|
||||
for (const [modName, error] of errors) {
|
||||
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
|
||||
errors.delete(modName);
|
||||
}
|
||||
}
|
||||
|
||||
return new lib.ModDependencyResolveRequest.Response(
|
||||
[...candidateReleases.values()], errors,
|
||||
);
|
||||
}
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
@@ -875,16 +626,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
|
||||
/** Stop following logs if the user no longer has permission to */
|
||||
checkLogPermission() {
|
||||
try {
|
||||
this.user.checkPermission("core.log.follow");
|
||||
} catch {
|
||||
this.logSubscriptions = { all: false, controller: false, hostIds: [], instanceIds: [] };
|
||||
this.updateLogSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
updateLogSubscriptions() {
|
||||
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
|
||||
if (all || controller || hostIds.length || instanceIds.length) {
|
||||
@@ -974,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`);
|
||||
}
|
||||
@@ -1014,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`);
|
||||
}
|
||||
@@ -1028,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`);
|
||||
}
|
||||
@@ -1062,39 +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);
|
||||
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.id === user.id) {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
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));
|
||||
@@ -1107,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);
|
||||
@@ -1136,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 = "";
|
||||
@@ -1148,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);
|
||||
@@ -1157,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));
|
||||
@@ -1167,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
|
||||
@@ -1230,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);
|
||||
}
|
||||
@@ -1261,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) {
|
||||
@@ -1287,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 });
|
||||
@@ -1302,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);
|
||||
}
|
||||
@@ -1310,15 +1054,11 @@ export default class ControlConnection extends BaseConnection {
|
||||
return usersToSend;
|
||||
}
|
||||
|
||||
async handleControllerMarkRestartRequiredRequest() {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
|
||||
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/controller");
|
||||
return lib.updatePackage("@clusterio/controller");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
@@ -1332,35 +1072,20 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!this._controller.config.get("controller.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
|
||||
return await lib.handlePluginInstall(request.pluginPackage);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this._controller.loadedPlugins.has(pluginInfo),
|
||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
this._controller.plugins.has(pluginInfo.name),
|
||||
this._controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
|
||||
) as boolean,
|
||||
));
|
||||
}
|
||||
|
||||
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||
if (this.ws_dumper) {
|
||||
return; // Already dumping
|
||||
}
|
||||
this.ws_dumper = data => {
|
||||
if (this.connector.connected) {
|
||||
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
|
||||
@@ -1370,3 +1095,5 @@ export default class ControlConnection extends BaseConnection {
|
||||
this._controller.debugEvents.on("message", this.ws_dumper);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ControlConnection;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,10 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type BaseConnection from "./BaseConnection.js";
|
||||
import type Controller from "./Controller";
|
||||
import type BaseConnection from "./BaseConnection";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
import HostConnection from "./HostConnection";
|
||||
import ControlConnection from "./ControlConnection";
|
||||
const { logger } = lib;
|
||||
|
||||
export default class ControllerRouter {
|
||||
constructor(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
import HostRecord from "./HostRecord.js";
|
||||
import BaseConnection from "./BaseConnection";
|
||||
import InstanceInfo from "./InstanceInfo";
|
||||
import HostInfo from "./HostInfo";
|
||||
|
||||
|
||||
// How long log message warnings are suppressed for after one is logged.
|
||||
const logMessageWarnIntervalMs = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Represents the connection to a host
|
||||
*
|
||||
@@ -19,10 +16,8 @@ const logMessageWarnIntervalMs = 5 * 60 * 1000;
|
||||
* @alias module:controller/src/HostConnection
|
||||
*/
|
||||
export default class HostConnection extends BaseConnection {
|
||||
private info: HostRecord;
|
||||
private info: HostInfo;
|
||||
plugins: Map<string, string>;
|
||||
// Shared by all log message warnings, a misbehaving host could log in a loop.
|
||||
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(
|
||||
registerData: lib.RegisterHostData,
|
||||
@@ -35,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,
|
||||
@@ -52,12 +47,17 @@ export default class HostConnection extends BaseConnection {
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
|
||||
// eslint-disable-next-line no-loop-func
|
||||
this.connector.on(event, () => {
|
||||
for (let plugin of this._controller.plugins.values()) {
|
||||
plugin.onHostConnectionEvent(this, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -66,9 +66,9 @@ export default class HostConnection extends BaseConnection {
|
||||
instances.push(instance);
|
||||
let prev = instance.status;
|
||||
instance.status = "unknown";
|
||||
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
this._controller.instances.records.setMany(instances);
|
||||
this._controller.instances.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
@@ -143,7 +143,7 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async prepareDisconnect() {
|
||||
await this._controller.hooks.prepareHostDisconnect.invoke(this);
|
||||
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
|
||||
return await super.prepareDisconnect();
|
||||
}
|
||||
|
||||
@@ -207,16 +207,11 @@ 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 this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
|
||||
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
|
||||
@@ -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,41 +246,41 @@ 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 this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
|
||||
await lib.invokeHook(
|
||||
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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 => [
|
||||
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
|
||||
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);
|
||||
}
|
||||
@@ -300,37 +295,7 @@ export default class HostConnection extends BaseConnection {
|
||||
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
|
||||
}
|
||||
|
||||
// A host may only report on instances assigned to it. Otherwise any host
|
||||
// could speak for an instance on a different host. See
|
||||
// handleInstanceStatusChangedEvent.
|
||||
private _assignedInstance(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
|
||||
}
|
||||
|
||||
// Host connections live for days, so the suppression expires instead of
|
||||
// latching for the life of the connection.
|
||||
private _warnLogMessage(message: string) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
|
||||
return;
|
||||
}
|
||||
this._lastLogMessageWarnMs = now;
|
||||
logger.warn(message);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
|
||||
// Otherwise any host could inject save records for, or wipe the save
|
||||
// list of, an instance on a different host.
|
||||
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
|
||||
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
|
||||
return;
|
||||
}
|
||||
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
|
||||
logger.warn(`Got bogus save updates from host id ${this.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: lib.SaveDetails[] = [];
|
||||
const deletes: lib.SaveDetails[] = [];
|
||||
for (const save of event.updates) {
|
||||
@@ -355,33 +320,8 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleLogMessageEvent(event: lib.LogMessageEvent) {
|
||||
// Fields beyond level and message survive schema validation, so the
|
||||
// instance a log line claims to come from is whatever the host sent.
|
||||
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
|
||||
if (info.instance_id !== undefined) {
|
||||
const instance = this._assignedInstance(info.instance_id);
|
||||
if (!instance) {
|
||||
// Demote to a host level line instead of dropping it. The host did
|
||||
// log this, it just does not get to pin it on someone else's
|
||||
// instance, and a host racing a reassignment still gets heard.
|
||||
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
|
||||
delete info.instance_id;
|
||||
delete info.instance_name;
|
||||
} else {
|
||||
// The name the host sent may be stale or made up, ours is authoritative.
|
||||
const name = instance.config.get("instance.name");
|
||||
if (info.instance_name !== name) {
|
||||
this._warnLogMessage(
|
||||
`Got log message for instance id ${info.instance_id} named ` +
|
||||
`${info.instance_name} which is named ${name} on the controller`
|
||||
);
|
||||
info.instance_name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clusterLogger.log({
|
||||
...info,
|
||||
...event.info,
|
||||
host_id: this.id,
|
||||
host_name: this.info.name,
|
||||
});
|
||||
@@ -389,21 +329,24 @@ 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 this._controller.hooks.playerEvent.invoke(instance, {
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.type,
|
||||
name: event.name,
|
||||
reason: event.reason,
|
||||
@@ -416,11 +359,15 @@ 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 this._controller.hooks.playerEvent.invoke(instance, {
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
@@ -432,17 +379,20 @@ 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 this._controller.hooks.playerEvent.invoke(instance, {
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
reason: event.reason,
|
||||
resaon: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,11 +401,15 @@ 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 this._controller.hooks.playerEvent.invoke(instance, {
|
||||
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
|
||||
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
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 this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
|
||||
private async _notifyConfigFieldChanged(
|
||||
instance: InstanceRecord,
|
||||
field: string,
|
||||
curr: unknown,
|
||||
prev: unknown,
|
||||
): Promise<void> {
|
||||
await this._controller.hooks.instanceConfigFieldChanged.invoke(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) {
|
||||
try {
|
||||
await newHostConnection.send(
|
||||
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
|
||||
);
|
||||
} catch (err) {
|
||||
// Leave unassigned so the assign can be retried
|
||||
instance.config.set("instance.assigned_host", null);
|
||||
instance.status = "unassigned";
|
||||
this.records.set(instance);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
|
||||
import {
|
||||
IUser, Role, SubscribableDatastore,
|
||||
PermissionError, PermissionName, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
import UserRecord from "./UserRecord.js";
|
||||
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: PermissionName) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,67 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fs from "fs-extra";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import 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,119 +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);
|
||||
}
|
||||
|
||||
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
|
||||
const tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
|
||||
this.records.set(user);
|
||||
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 },
|
||||
// auth secret is never null after startup
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,18 +1,18 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type Controller from "./Controller.js";
|
||||
import type Controller from "./Controller";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import WsServerConnector from "./WsServerConnector.js";
|
||||
import ControlConnection from "./ControlConnection";
|
||||
import HostConnection from "./HostConnection";
|
||||
import WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
import { version as packageVersion } from "../package.json";
|
||||
|
||||
const wsMessageCounter = new lib.Counter(
|
||||
"clusterio_controller_websocket_message_total",
|
||||
@@ -45,12 +45,12 @@ export default class WsServer {
|
||||
sessionAud: string = `session-${Date.now()}`;
|
||||
nextSessionId: number = 1;
|
||||
nextControlId: number = 1;
|
||||
wss: WebSocketServer;
|
||||
wss: WebSocket.Server;
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controller = controller;
|
||||
|
||||
this.wss = new WebSocketServer({
|
||||
this.wss = new WebSocket.Server({
|
||||
noServer: true,
|
||||
path: "/api/socket",
|
||||
});
|
||||
@@ -116,13 +116,6 @@ export default class WsServer {
|
||||
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
|
||||
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
|
||||
|
||||
// ws emits errors like invalid frames on the socket, and an error with
|
||||
// no listener crashes the controller. The connector adds its own
|
||||
// listener, but only once the handshake has completed.
|
||||
socket.on("error", (err: Error) => {
|
||||
logger.verbose(`WsServer | error on connection from ${this.remoteAddr(req)}: ${err.message}`);
|
||||
});
|
||||
|
||||
// Track statistics
|
||||
wsConnectionsCounter.inc();
|
||||
socket.on("message", (message: WebSocket.RawData) => {
|
||||
@@ -142,11 +135,11 @@ export default class WsServer {
|
||||
|
||||
// Start connection handshake.
|
||||
let loadedPlugins: {[key:string]: string} = {};
|
||||
for (const plugin of this.controller.loadedPlugins) {
|
||||
loadedPlugins[plugin.name] = plugin.version;
|
||||
for (let [name, plugin] of this.controller.plugins) {
|
||||
loadedPlugins[name] = plugin.info.version;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
|
||||
this.attachHandler(socket, req);
|
||||
}
|
||||
|
||||
@@ -230,11 +223,13 @@ ${err.stack}`
|
||||
createSession(dst: lib.Address): [ WsServerConnector, string ] {
|
||||
let sessionId = this.nextSessionId;
|
||||
this.nextSessionId += 1;
|
||||
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
|
||||
let sessionToken = jwt.sign(
|
||||
{ aud: this.sessionAud, sid: sessionId },
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
|
||||
this.activeConnectors.set(sessionId, connector);
|
||||
connector.on("close", () => {
|
||||
this.activeConnectors.delete(sessionId);
|
||||
@@ -252,8 +247,13 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
{ audience: ["host", "slave"] }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -295,11 +295,10 @@ ${err.stack}`
|
||||
logger.info(
|
||||
`WsServer | registered host ${data.id} using version ${data.version}`
|
||||
);
|
||||
if (data.version !== packageConfig.version) {
|
||||
if (data.version !== packageVersion) {
|
||||
logger.warn(
|
||||
`Host ${data.id} connected using version ${data.version} which does not match the ` +
|
||||
`version of the controller is currently running (${packageConfig.version}). ` +
|
||||
"It may not work as expected."
|
||||
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,12 +324,17 @@ ${err.stack}`
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.users.getByNameMutable(tokenPayload.user);
|
||||
user = this.controller.userManager.getByName(tokenPayload.user);
|
||||
if (!user) {
|
||||
throw new Error("invalid user");
|
||||
}
|
||||
@@ -372,7 +376,12 @@ ${err.stack}`
|
||||
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
|
||||
let connector;
|
||||
try {
|
||||
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
|
||||
let payload = jwt.verify(
|
||||
data.sessionToken,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: this.sessionAud }
|
||||
);
|
||||
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type WebSocket } from "ws";
|
||||
import type WebSocket from "ws";
|
||||
|
||||
import events from "events";
|
||||
import assert from "assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,10 +21,8 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
private _sessionId: number,
|
||||
private _sessionTimeout: number,
|
||||
public _heartbeatInterval: number,
|
||||
maxSendBufferSize = 0,
|
||||
) {
|
||||
super(new lib.Address(lib.Address.controller, 0), dst);
|
||||
this.maxSendBufferSize = maxSendBufferSize;
|
||||
|
||||
// The following states are used in the server connector
|
||||
// closed: Connection is closed
|
||||
@@ -51,7 +49,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
* @param account - account data to provide to control connection
|
||||
*/
|
||||
ready(
|
||||
socket: WebSocket,
|
||||
socket: WebSocket.WebSocket,
|
||||
src: lib.Address,
|
||||
sessionToken: string,
|
||||
account: lib.AccountDetails | undefined
|
||||
@@ -82,7 +80,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
* @param lastSeq - The last message the client received.
|
||||
*/
|
||||
continue(
|
||||
socket: WebSocket,
|
||||
socket: WebSocket.WebSocket,
|
||||
lastSeq: number
|
||||
) {
|
||||
|
||||
@@ -108,17 +106,14 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
this._state = "connected";
|
||||
this._attachSocketHandlers();
|
||||
this._dropSendBufferSeq(lastSeq);
|
||||
logger.verbose(
|
||||
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
|
||||
);
|
||||
for (let message of this._sendBuffer) {
|
||||
this._socket.send(message.text);
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
this.emit("resume");
|
||||
}
|
||||
|
||||
_timedOut() {
|
||||
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
|
||||
logger.verbose("Connector | Connection timed out");
|
||||
this._close();
|
||||
}
|
||||
|
||||
@@ -144,6 +139,11 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
}
|
||||
});
|
||||
|
||||
this._socket.on("error", (err: any) => {
|
||||
// It's assumed that close is always called by ws
|
||||
logger.verbose("Connector | Error:", err);
|
||||
});
|
||||
|
||||
this._socket.on("open", () => {
|
||||
logger.verbose("Connector | Open");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import Controller from "./Controller.js";
|
||||
import type Controller from "./Controller";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "node:fs/promises";
|
||||
import fs from "fs-extra";
|
||||
import JSZip from "jszip";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
@@ -12,7 +12,7 @@ import nodeStream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
|
||||
const finished = util.promisify(nodeStream.finished);
|
||||
|
||||
@@ -22,10 +22,8 @@ declare global {
|
||||
export interface Locals {
|
||||
controller: Controller,
|
||||
mainBundle: string,
|
||||
staticRoot: string | null,
|
||||
devPlugins: Map<string, number>,
|
||||
streams: Map<string, ProxyStream>,
|
||||
pendingMetrics?: Promise<string>,
|
||||
streams: Map<string, ProxyStream>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,10 +43,12 @@ function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceRe
|
||||
}
|
||||
}
|
||||
|
||||
// Collect metrics from the controller, its plugins and all connected hosts
|
||||
async function gatherMetrics(controller: Controller) {
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response, next: any) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
|
||||
let results: lib.CollectorResult[] = [];
|
||||
let pluginResults = await controller.hooks.metrics.collect();
|
||||
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
|
||||
for (let metricIterator of pluginResults) {
|
||||
for await (let metric of metricIterator) {
|
||||
results.push(metric);
|
||||
@@ -98,46 +98,20 @@ async function gatherMetrics(controller: Controller) {
|
||||
results.push(lib.deserializeResult(result));
|
||||
}
|
||||
|
||||
return await lib.exposition(results);
|
||||
}
|
||||
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response) {
|
||||
try {
|
||||
res.locals.user.checkPermission("core.controller.metrics");
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ request_errors: [err.message] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Concurrent scrapes share one gather so hosts see at most one
|
||||
// metrics request at a time regardless of how many clients poll.
|
||||
const locals = req.app.locals;
|
||||
if (!locals.pendingMetrics) {
|
||||
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
|
||||
locals.pendingMetrics = undefined;
|
||||
});
|
||||
}
|
||||
const text = await locals.pendingMetrics;
|
||||
let text = await lib.exposition(results);
|
||||
res.set("Content-Type", lib.expositionContentType);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes the static/ prefix webpack adds to bundle paths so they can be
|
||||
* resolved relative to staticRoot.
|
||||
*/
|
||||
export function stripStaticPrefix(bundlePath: string) {
|
||||
return bundlePath.replace(/^static\//, "");
|
||||
}
|
||||
|
||||
function getPlugins(req: Request, res: Response) {
|
||||
let plugins: lib.PluginWebApi[] = [];
|
||||
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
|
||||
let name = pluginInfo.name;
|
||||
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
|
||||
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
|
||||
let loaded = req.app.locals.controller.plugins.has(name);
|
||||
let enabled = loaded && req.app.locals.controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` 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.
|
||||
@@ -145,54 +119,37 @@ function getPlugins(req: Request, res: Response) {
|
||||
let devPlugins = req.app.locals.devPlugins;
|
||||
if (devPlugins && devPlugins.has(name)) {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
||||
web.main = stats.toJson().assetsByChunkName[name][0];
|
||||
web.main = stats.toJson().assetsByChunkName[name];
|
||||
} else if (pluginInfo.manifest) {
|
||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
||||
if (!web.main) {
|
||||
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
|
||||
}
|
||||
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||
} else {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
if (web.main) {
|
||||
web.main = stripStaticPrefix(web.main);
|
||||
}
|
||||
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
|
||||
}
|
||||
res.send(plugins);
|
||||
}
|
||||
|
||||
function getClusterName(req: Request, res: Response) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
const clusterName = controller.config.get("controller.name");
|
||||
res.json({ name: clusterName });
|
||||
}
|
||||
|
||||
// Reads the access token from X-Access-Token or Authorization: Bearer
|
||||
function getAccessToken(req: Request) {
|
||||
const token = req.header("x-access-token");
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
const authorization = req.header("authorization");
|
||||
if (authorization && /^Bearer /i.test(authorization)) {
|
||||
return authorization.slice("Bearer ".length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateHostToken(req: Request, res: Response, next: any) {
|
||||
let token = getAccessToken(req);
|
||||
let token = req.header("x-access-token");
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
|
||||
const tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "host" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -212,18 +169,23 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
function validateUserToken(req: Request, res: Response, next: any) {
|
||||
let token = getAccessToken(req);
|
||||
let token = req.header("x-access-token");
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
let tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -274,14 +236,13 @@ async function uploadExport(req: Request, res: Response) {
|
||||
let exportFiles = [
|
||||
"export/settings.json",
|
||||
"export/prototypes.json",
|
||||
"export/item-spritesheet.png",
|
||||
"export/item-metadata.json",
|
||||
"export/locale.json",
|
||||
"export/defines.json",
|
||||
"export/spritesheet.png",
|
||||
"export/metadata.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes: lib.ExportSettings = {};
|
||||
let settingPrototypes = {};
|
||||
for (let filePath of exportFiles) {
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
@@ -298,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);
|
||||
|
||||
@@ -349,7 +310,7 @@ export async function createProxyStream(app: Application): Promise<ProxyStream>
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function putStream(req: Request<{ id: string }>, res: Response) {
|
||||
async function putStream(req: Request, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.source) {
|
||||
res.sendStatus(404);
|
||||
@@ -388,7 +349,7 @@ function startStream(res: Response, stream: ProxyStream) {
|
||||
clearTimeout(stream.timeout);
|
||||
}
|
||||
|
||||
async function getStream(req: Request<{ id: string }>, res: Response) {
|
||||
async function getStream(req: Request, res: Response) {
|
||||
const stream = req.app.locals.streams.get(req.params.id);
|
||||
if (!stream || stream.flowing) {
|
||||
res.sendStatus(404);
|
||||
@@ -587,16 +548,30 @@ async function uploadMod(req: Request, res: Response) {
|
||||
}
|
||||
|
||||
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
|
||||
const filePath = path.join(modsDirectory, filename);
|
||||
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
|
||||
let writeStream;
|
||||
try {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
|
||||
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
|
||||
checkModName(modInfo.filename);
|
||||
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
|
||||
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
|
||||
req.app.locals.controller.modStore.addMod(modInfo);
|
||||
mods.push(modInfo.toJSON());
|
||||
|
||||
@@ -606,13 +581,15 @@ async function uploadMod(req: Request, res: Response) {
|
||||
stream.resume();
|
||||
|
||||
// Attempt to clean up.
|
||||
writeStream.destroy();
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(tempFilePath);
|
||||
await fs.unlink(path.join(modsDirectory, tempFilename));
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") {
|
||||
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
|
||||
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,22 +662,14 @@ async function uploadMod(req: Request, res: Response) {
|
||||
|
||||
|
||||
export function addRouteHandlers(app: Application) {
|
||||
app.get("/metrics",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
|
||||
);
|
||||
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
|
||||
app.get("/api/plugins", getPlugins);
|
||||
app.get("/api/cluster-name", getClusterName);
|
||||
app.put("/api/upload-export",
|
||||
validateHostToken,
|
||||
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
|
||||
);
|
||||
app.put("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
|
||||
);
|
||||
app.get("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, res:Response, next:any) => getStream(req, res).catch(next)
|
||||
);
|
||||
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
|
||||
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
|
||||
app.post("/api/upload-save",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
|
||||
@@ -721,7 +690,6 @@ export const webRoutes = [
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/mods/mod-packs/:id/icon-reference",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
||||
<script>
|
||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
|
||||
function scriptError(err) {
|
||||
console.error(err);
|
||||
let bootstrapMsg = document.getElementById("bootstrap-msg");
|
||||
|
||||
@@ -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: {
|
||||
+130
-74
@@ -1,19 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import child_process from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import inquirer from "inquirer";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import stream from "node:stream";
|
||||
import util from "node:util";
|
||||
import yargs from "yargs";
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs-extra");
|
||||
const inquirer = require("inquirer");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
import { levels, logger, setLogLevel } from "./logging.js";
|
||||
import { copyPluginTemplates } from "./template.js";
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
const { copyPluginTemplates } = require("./template");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
import { escapeArg } from "./escape_arg.js";
|
||||
const finished = util.promisify(stream.finished);
|
||||
const { escapeArg } = require("./escape_arg");
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
@@ -30,8 +32,8 @@ if (process.platform === "win32") {
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
|
||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
||||
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
||||
@@ -95,36 +97,24 @@ class LineSplitter extends stream.Transform {
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
const command = [cmd, ...args.map(escapeArg)].join(" ");
|
||||
logger.verbose(`executing ${command}`);
|
||||
const escaped = args.map(escapeArg);
|
||||
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(
|
||||
command,
|
||||
cmd,
|
||||
escaped,
|
||||
{
|
||||
shell: true,
|
||||
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
@@ -146,21 +136,21 @@ async function execFile(cmd, args) {
|
||||
|
||||
async function execController(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
|
||||
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execHost(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
|
||||
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
|
||||
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
@@ -231,10 +221,10 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateConfig(source, destination) {
|
||||
if (await pathExists(source) && !await pathExists(destination)) {
|
||||
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
), { mode: 0o600 });
|
||||
));
|
||||
logger.info(`Migrated ${source} to ${destination}`);
|
||||
}
|
||||
}
|
||||
@@ -243,7 +233,7 @@ async function migrateRename(args) {
|
||||
await migrateConfig("config-slave.json", "config-host.json");
|
||||
|
||||
let instancesFile = path.join("database", "instances.json");
|
||||
if (await pathExists(instancesFile)) {
|
||||
if (await fs.pathExists(instancesFile)) {
|
||||
await safeOutputFile(instancesFile, JSON.stringify(
|
||||
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
||||
));
|
||||
@@ -251,7 +241,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
let usersFile = path.join("database", "users.json");
|
||||
if (await pathExists(usersFile)) {
|
||||
if (await fs.pathExists(usersFile)) {
|
||||
let users = JSON.parse(await fs.readFile(usersFile));
|
||||
for (let role of users["roles"]) {
|
||||
role["permissions"] = role["permissions"].map(rename);
|
||||
@@ -268,7 +258,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function migrateLog(inputFile, outputFile) {
|
||||
let lineStream = new LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = (await fs.open(inputFile)).createReadStream();
|
||||
let fileStream = fs.createReadStream(inputFile);
|
||||
fileStream.pipe(lineStream);
|
||||
let lines = [];
|
||||
|
||||
@@ -288,7 +278,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateLogsDir(inputLogs, outputLogs) {
|
||||
if (await pathExists(inputLogs)) {
|
||||
if (await fs.pathExists(inputLogs)) {
|
||||
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
|
||||
for (let logFile of await fs.readdir(inputLogs)) {
|
||||
if (!logFile.endsWith(".log")) {
|
||||
@@ -296,7 +286,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
let inputFile = path.join(inputLogs, logFile);
|
||||
let outputFile = path.join(outputLogs, rename(logFile));
|
||||
if (!await pathExists(outputFile)) {
|
||||
if (!await fs.pathExists(outputFile)) {
|
||||
await migrateLog(inputFile, outputFile);
|
||||
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
|
||||
}
|
||||
@@ -307,16 +297,16 @@ async function migrateRename(args) {
|
||||
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
|
||||
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
|
||||
if (
|
||||
await pathExists(path.join("logs", "cluster"))
|
||||
&& !await pathExists(path.join("logs", "cluster-prerename"))
|
||||
await fs.pathExists(path.join("logs", "cluster"))
|
||||
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
|
||||
) {
|
||||
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
|
||||
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
||||
}
|
||||
|
||||
if (
|
||||
await pathExists("sharedMods")
|
||||
&& !await pathExists("mods")
|
||||
await fs.pathExists("sharedMods")
|
||||
&& !await fs.pathExists("mods")
|
||||
) {
|
||||
logger.info("Moving sharedMods/ to mods/");
|
||||
await fs.rename("sharedMods", "mods");
|
||||
@@ -338,14 +328,14 @@ async function migrateRename(args) {
|
||||
await execFile(`npm${scriptExt}`, ["update"]);
|
||||
}
|
||||
|
||||
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
|
||||
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
|
||||
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
|
||||
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
|
||||
if (hasRunMaster || hasRunSlave) {
|
||||
logger.info("Writing run scripts");
|
||||
let mode = "standalone";
|
||||
if (!hasRunSlave) { mode = "controller"; }
|
||||
if (!hasRunMaster) { mode = "host"; }
|
||||
await writeScripts(mode, "");
|
||||
await writeScripts(mode);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -364,7 +354,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.writeFile("package.json", JSON.stringify({
|
||||
await fs.outputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -406,8 +396,8 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
|
||||
let pluginInfo = (await import(plugin)).plugin;
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
@@ -430,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(
|
||||
@@ -484,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
|
||||
@@ -498,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",
|
||||
`\
|
||||
@@ -525,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
|
||||
|
||||
@@ -653,10 +640,26 @@ async function inquirerMissingArgs(args) {
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -681,12 +684,11 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
await fs.ensureDir("factorio");
|
||||
}
|
||||
}
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
@@ -748,8 +750,56 @@ async function inquirerMissingArgs(args) {
|
||||
return answers;
|
||||
}
|
||||
|
||||
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 filename = path.posix.basename(url.pathname);
|
||||
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||
if (!match || !match.length) {
|
||||
throw Error(`Unable to extract version from filename: ${filename}`);
|
||||
}
|
||||
const version = match[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.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);
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let yarg = yargs(process.argv.slice(2))
|
||||
let args = yargs
|
||||
.option("log-level", {
|
||||
nargs: 1, describe: "Log level to print to stdout", default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)), type: "string",
|
||||
@@ -785,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",
|
||||
})
|
||||
@@ -804,14 +851,20 @@ async function main() {
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
yarg = yarg
|
||||
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",
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
const args = yarg.parse();
|
||||
args = args.argv;
|
||||
|
||||
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
|
||||
dev = args.dev;
|
||||
@@ -834,6 +887,13 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
@@ -841,7 +901,6 @@ async function main() {
|
||||
let adminToken = null;
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["config", "create"]);
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
@@ -852,7 +911,6 @@ async function main() {
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
logger.info("Setting up host");
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.name", "local"]);
|
||||
|
||||
let result = await execHost(["config", "show", "host.id"]);
|
||||
@@ -873,7 +931,6 @@ async function main() {
|
||||
if (answers.mode === "host") {
|
||||
logger.info("Setting up host");
|
||||
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.id", hostId]);
|
||||
await execHost(["config", "set", "host.name", answers.hostName]);
|
||||
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
|
||||
@@ -886,13 +943,12 @@ 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") {
|
||||
await execCtl(["config", "create"]);
|
||||
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
|
||||
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
@@ -903,7 +959,7 @@ async function main() {
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
main().catch(err => {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export function escapeArg(arg) {
|
||||
"use strict";
|
||||
|
||||
function escapeArg(arg) {
|
||||
// When shell: true is used execFile does not escaping of arguments, they are
|
||||
// simply joined by space and then passed to the shell.
|
||||
|
||||
@@ -48,9 +50,13 @@ export 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;
|
||||
}
|
||||
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeArg,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable no-console */
|
||||
import chalk from "chalk";
|
||||
"use strict";
|
||||
const chalk = require("chalk");
|
||||
|
||||
export const levels = Object.freeze({
|
||||
const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
@@ -10,7 +11,7 @@ export const levels = Object.freeze({
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
export const colors = {
|
||||
const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold redBright",
|
||||
warn: "bold yellowBright",
|
||||
@@ -20,7 +21,7 @@ export const colors = {
|
||||
};
|
||||
|
||||
let maxLogLevel = levels.info;
|
||||
export const logger = {};
|
||||
const logger = {};
|
||||
for (let [name, level] of Object.entries(levels)) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
logger[name] = message => {
|
||||
@@ -41,6 +42,10 @@ for (let [name, level] of Object.entries(levels)) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setLogLevel(level) {
|
||||
maxLogLevel = level;
|
||||
}
|
||||
module.exports = {
|
||||
colors,
|
||||
levels,
|
||||
setLogLevel: (level) => { maxLogLevel = level; },
|
||||
logger,
|
||||
};
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
{
|
||||
"name": "@clusterio/create",
|
||||
"version": "2.0.1",
|
||||
"description": "Installer for clusterio",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"chalk": "catalog:",
|
||||
"chalk": "^4.1.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"inquirer": "^8.2.4",
|
||||
"yargs": "catalog:"
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
+22
-34
@@ -1,8 +1,7 @@
|
||||
import { logger } from "./logging.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import _package from "./package.json" with { type: "json" };
|
||||
"use strict";
|
||||
const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
@@ -60,15 +59,9 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
return include ? content : "";
|
||||
});
|
||||
|
||||
// Create folder for output file
|
||||
const { dir } = path.parse(dst);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Attempt to write the output file, warn if it already exists
|
||||
try {
|
||||
await fs.writeFile(dst, outputData, { flag: "wx" });
|
||||
await fs.outputFile(dst, outputData, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
logger.warn(`Could not create file ${dst} because it already exists`);
|
||||
@@ -78,7 +71,7 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyPluginTemplates(pluginName, templates) {
|
||||
async function copyPluginTemplates(pluginName, templates) {
|
||||
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
|
||||
const files = new Map();
|
||||
const prepare = [];
|
||||
@@ -95,15 +88,12 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Get the file extension and path to the templates
|
||||
const ext = javascriptOnly ? "js" : "ts";
|
||||
const templatePath = path.resolve(
|
||||
import.meta.dirname,
|
||||
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
|
||||
);
|
||||
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
|
||||
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
|
||||
const commonPath = path.resolve(__dirname, "./templates/common");
|
||||
|
||||
// Files included in all templates
|
||||
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
|
||||
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}`));
|
||||
|
||||
@@ -112,7 +102,6 @@ export 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"));
|
||||
}
|
||||
@@ -120,8 +109,8 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
||||
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`));
|
||||
pluginContexts += 1;
|
||||
@@ -150,6 +139,7 @@ export 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"));
|
||||
@@ -157,6 +147,7 @@ export 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,25 +164,18 @@ export 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
|
||||
@@ -204,3 +188,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
await Promise.all(writes);
|
||||
logger.info("Successfully wrote all template files");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
copyPluginTemplates,
|
||||
};
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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")
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "__plugin_name__",
|
||||
"version": "0.1.0",
|
||||
"description": "Example Description. Package. Change me in package.json",
|
||||
"type": "module",
|
||||
//%if typescript
|
||||
"main": "dist/node/index.js",
|
||||
//%endif
|
||||
@@ -13,81 +12,36 @@
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": "__node_version__"
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
//%if controller | host | ctl
|
||||
"peerDependenciesMeta": {
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller & host | controller & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if host & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller | host | ctl
|
||||
}
|
||||
},
|
||||
//%endif
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.13.4",
|
||||
"typescript": "^5.5.3",
|
||||
"@types/node": "^20.4.5",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react": "^18.2.21",
|
||||
//%endif
|
||||
//%// Everything else
|
||||
//%if web
|
||||
"antd": "^5.29.3",
|
||||
"antd": "^5.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"webpack": "^5.110.3",
|
||||
"webpack-cli": "^7.2.3",
|
||||
//%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.6"
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+3
-1
@@ -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,66 +1,83 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context // Messages requires multi context
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} = require("./messages");
|
||||
//%endif
|
||||
|
||||
export default async function loadControllerPlugin(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
exampleDatabase;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
controller.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
|
||||
controller.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance, event) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
async handleExampleSubscription(request) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
"use strict";
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
const { CommandTree, Command } = require("@clusterio/lib");
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
@@ -11,16 +13,20 @@ pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args, ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
handler: async function(args, control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export default async function loadCtlPlugin(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
rootCommand.add(pluginCommands);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
};
|
||||
@@ -1,30 +1,41 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseHostPlugin } = require("@clusterio/host");
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
//%endif
|
||||
|
||||
export default async function loadHostPlugin(context) {
|
||||
const { host, logger, plugin } = context;
|
||||
class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
host.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
host.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
|
||||
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`host::hostConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
host.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("host::shutdown");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HostPlugin,
|
||||
};
|
||||
@@ -1,9 +1,38 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
//%if multi_context
|
||||
import * as Messages from "./messages.js";
|
||||
const Messages = require("./messages");
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
|
||||
export const plugin = {
|
||||
const plugin = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.js",
|
||||
@@ -11,7 +40,7 @@ export const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
@@ -27,7 +56,7 @@ export const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host.js",
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
@@ -43,7 +72,7 @@ export const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
@@ -59,12 +88,12 @@ export const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl.js",
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
ctlConfigFields: {
|
||||
"__plugin_name__.myCtlField": {
|
||||
title: "My Ctl Field",
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
@@ -83,43 +112,6 @@ export const plugin = {
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
|
||||
permissions: [
|
||||
//%endif
|
||||
//%if multi_context
|
||||
{
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
{
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if web
|
||||
{
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
@@ -128,3 +120,7 @@ export const plugin = {
|
||||
],
|
||||
//%endif
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
@@ -1,56 +1,66 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
//%endif
|
||||
|
||||
export default async function loadInstancePlugin(context) {
|
||||
const { instance, logger, plugin } = context;
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
instance.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
instance.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleIPC(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
//%endif
|
||||
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`instance::instanceConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
// Called once rcon becomes available
|
||||
logger.info("instance::start");
|
||||
});
|
||||
|
||||
instance.hooks.stop.attach(plugin.name, async () => {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
logger.info("instance::stop");
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, () => {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
logger.info("instance::exit");
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
|
||||
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (instance.status === "running") {
|
||||
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
|
||||
}
|
||||
//%endif
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
export default async function loadInstancePlugin(context) { }
|
||||
"use strict";
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
"use strict";
|
||||
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
|
||||
const { Type } = require("@sinclair/typebox");
|
||||
|
||||
export class PluginExampleEvent {
|
||||
class PluginExampleEvent {
|
||||
static type = "event";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
@@ -23,7 +24,7 @@ export class PluginExampleEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginExampleRequest {
|
||||
class PluginExampleRequest {
|
||||
static type = "request";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
@@ -51,7 +52,7 @@ export class PluginExampleRequest {
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
export class ExampleSubscribableValue {
|
||||
class ExampleSubscribableValue {
|
||||
constructor(id, updatedAtMs, isDeleted) {
|
||||
this.id = id;
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
@@ -69,7 +70,7 @@ export class ExampleSubscribableValue {
|
||||
}
|
||||
}
|
||||
|
||||
export class ExampleSubscribableUpdate {
|
||||
class ExampleSubscribableUpdate {
|
||||
static type = "event";
|
||||
static src = "controller";
|
||||
static dst = "control";
|
||||
@@ -89,3 +90,12 @@ export class ExampleSubscribableUpdate {
|
||||
}
|
||||
}
|
||||
//%endif
|
||||
|
||||
module.exports = {
|
||||
PluginExampleEvent,
|
||||
PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableValue,
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Messages requires multi context
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,27 +20,17 @@ import {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages.js";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
// Created when the plugin is loaded, before any page is rendered
|
||||
let exampleSubscriber;
|
||||
|
||||
function useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
function MyTemplatePage() {
|
||||
let control = useContext(ControlContext);
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const [subscribableData, synced] = useSubscribableData();
|
||||
const plugin = control.plugins.get("__plugin_name__");
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
@@ -51,39 +41,53 @@ function MyTemplatePage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export default async function loadWebPlugin(context) {
|
||||
const { control, logger, plugin } = context;
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
|
||||
//%endif
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: "__plugin_name__.page.view",
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
]);
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
//%if multi_context
|
||||
|
||||
control.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller// Subscribing requires web content and the controller
|
||||
|
||||
control.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { ControllerPluginContext, 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,62 +8,71 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "./messages";
|
||||
//%endif
|
||||
|
||||
export default async function loadControllerPlugin(context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
exampleDatabase!: Map<string, ExampleSubscribableValue>;
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
}
|
||||
//%if instance
|
||||
|
||||
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onSaveData() {
|
||||
this.logger.info("controller::onSaveData");
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("controller::onShutdown");
|
||||
}
|
||||
|
||||
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(
|
||||
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
);
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
|
||||
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
@@ -12,16 +12,16 @@ pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { foo: string }, ctl: Ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
handler: async function(args: { foo: string }, control: Control) {
|
||||
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export default async function loadCtlPlugin(context: CtlPluginContext) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
export class CtlPlugin extends BaseCtlPlugin {
|
||||
async addCommands(rootCommand: CommandTree) {
|
||||
rootCommand.add(pluginCommands);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,36 @@
|
||||
import type { HostPluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseHostPlugin } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
//%endif
|
||||
|
||||
export default async function loadHostPlugin(context: HostPluginContext) {
|
||||
const { host, logger, plugin } = context;
|
||||
export class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
|
||||
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`host::hostConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
host.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("host::shutdown");
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,36 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import * as Messages from "./messages.js";
|
||||
import * as Messages from "./messages";
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if config | multi_context | web
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if config
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
//%endif
|
||||
@@ -22,27 +50,11 @@ declare module "@clusterio/lib" {
|
||||
}
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
export interface CtlConfigFields {
|
||||
"__plugin_name__.myCtlField": string;
|
||||
export interface ControlConfigFields {
|
||||
"__plugin_name__.myControlField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
export interface Permissions {
|
||||
//%endif
|
||||
//%if multi_context
|
||||
"__plugin_name__.example.permission.event": never;
|
||||
"__plugin_name__.example.permission.request": never;
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
"__plugin_name__.example.permission.subscribe": never;
|
||||
//%endif
|
||||
//%if web
|
||||
"__plugin_name__.page.view": never;
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
}
|
||||
//%endif
|
||||
//%if config | multi_context | web
|
||||
//%if config
|
||||
}
|
||||
//%endif
|
||||
|
||||
@@ -55,7 +67,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
@@ -71,7 +83,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host.js",
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
@@ -87,7 +99,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
@@ -103,12 +115,12 @@ export const plugin: lib.PluginDeclaration = {
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl.js",
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
ctlConfigFields: {
|
||||
"__plugin_name__.myCtlField": {
|
||||
title: "My Ctl Field",
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
@@ -127,43 +139,6 @@ export const plugin: lib.PluginDeclaration = {
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
|
||||
permissions: [
|
||||
//%endif
|
||||
//%if multi_context
|
||||
{
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
{
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if web
|
||||
{
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
type PluginExampleIPC = {
|
||||
type PuginExampleIPC = {
|
||||
tick: number,
|
||||
player_name: string,
|
||||
};
|
||||
//%endif
|
||||
|
||||
export default async function loadInstancePlugin(context: InstancePluginContext) {
|
||||
const { instance, logger, plugin } = context;
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%endif
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
async handlePluginExampleIPC(event: PuginExampleIPC) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
//%endif
|
||||
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`instance::instanceConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
// Called once rcon becomes available
|
||||
logger.info("instance::start");
|
||||
});
|
||||
|
||||
instance.hooks.stop.attach(plugin.name, async () => {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
logger.info("instance::stop");
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, () => {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
logger.info("instance::exit");
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
|
||||
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (instance.status === "running") {
|
||||
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
|
||||
}
|
||||
//%endif
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
|
||||
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
export default async function loadInstancePlugin(context: InstancePluginContext) { }
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
|
||||
"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/*"],
|
||||
}
|
||||
@@ -10,10 +10,9 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
type WebPluginContext,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Messages requires multi context
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -21,27 +20,17 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages.js";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
// Created when the plugin is loaded, before any page is rendered
|
||||
let exampleSubscriber: lib.MapSubscriber<ExampleSubscribableUpdate>;
|
||||
|
||||
function useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
function MyTemplatePage() {
|
||||
const control = useContext(ControlContext);
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const [subscribableData, synced] = useSubscribableData();
|
||||
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
@@ -52,39 +41,53 @@ function MyTemplatePage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export default async function loadWebPlugin(context: WebPluginContext) {
|
||||
const { control, logger, plugin } = context;
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
|
||||
//%endif
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%endif
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: "__plugin_name__.page.view",
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
]);
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
//%if multi_context
|
||||
|
||||
control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request: PluginExampleRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+10
-31
@@ -17,12 +17,9 @@ Common options:
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for ctl.
|
||||
Defaults to `config-ctl.json` and is required for operation.
|
||||
Defaults to `config-control.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
|
||||
Alternative to the `config create` command.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
@@ -30,28 +27,17 @@ Configure plugins available to be loaded by ctl.
|
||||
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
|
||||
|
||||
|
||||
#### `plugin install <package>`
|
||||
|
||||
Install a plugin from npm and add it to the plugin list.
|
||||
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
|
||||
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusterioctl plugin install @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
|
||||
|
||||
For example, adding a plugin checked out in the external_plugins directory:
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusterioctl plugin add ./external_plugins/my_plugin
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
@@ -73,20 +59,13 @@ Since the `plugin-list.json` is shared between controller, host and ctl you usua
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config`
|
||||
### `config-control`
|
||||
|
||||
Manage the ctl configuration.
|
||||
This allows setting the url and token to the controller to manage.
|
||||
|
||||
|
||||
#### `config create`
|
||||
|
||||
Create a new config file with the default options inserted into if it doesn't already exist.
|
||||
|
||||
See also the `--create-config` option.
|
||||
|
||||
|
||||
#### `config set <config-entry> [value]`
|
||||
#### `config-control set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
@@ -95,12 +74,12 @@ If the config entry is of type object the value must be a valid JSON serializati
|
||||
See docs/configuration.md in the main repositiory for the available configuration.
|
||||
|
||||
|
||||
#### `config show <config-entry>`
|
||||
#### `config-control show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config list`
|
||||
#### `config-control list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
+118
-92
@@ -4,30 +4,31 @@
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import path from "path";
|
||||
import packageConfig from "@clusterio/ctl/package.json" with { type: "json" };
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
import "./src/set_lazy_schema_compliation.js";
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import * as commands from "./src/commands.js";
|
||||
import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
|
||||
import * as commands from "./src/commands";
|
||||
import BaseCtlPlugin from "./src/BaseCtlPlugin";
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to controller
|
||||
* @private
|
||||
*/
|
||||
class CtlConnector extends lib.WebSocketClientConnector {
|
||||
class ControlConnector extends lib.WebSocketClientConnector {
|
||||
private _token: string;
|
||||
|
||||
constructor(url: string, maxReconnectDelay: number, token: string) {
|
||||
super(url, maxReconnectDelay);
|
||||
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
|
||||
super(url, maxReconnectDelay, tlsCa);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
@@ -37,7 +38,7 @@ class CtlConnector extends lib.WebSocketClientConnector {
|
||||
new lib.MessageRegisterControl(
|
||||
new lib.RegisterControlData(
|
||||
this._token,
|
||||
packageConfig.version,
|
||||
version,
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -45,23 +46,31 @@ class CtlConnector extends lib.WebSocketClientConnector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running ctl
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the controller over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Ctl extends lib.Link {
|
||||
/** Ctl config used for connecting to the controller. */
|
||||
config: lib.CtlConfig;
|
||||
export class Control extends lib.Link {
|
||||
/** Control config used for connecting to the controller. */
|
||||
config: lib.ControlConfig;
|
||||
/** 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. */
|
||||
keepOpen = false;
|
||||
|
||||
constructor(
|
||||
connector: CtlConnector,
|
||||
ctlConfig: lib.CtlConfig,
|
||||
connector: ControlConnector,
|
||||
controlConfig: lib.ControlConfig,
|
||||
tlsCa: string | undefined,
|
||||
ctlPlugins: Map<string, BaseCtlPlugin>
|
||||
) {
|
||||
super(connector);
|
||||
this.config = ctlConfig;
|
||||
this.config = controlConfig;
|
||||
this.tlsCa = tlsCa;
|
||||
this.plugins = ctlPlugins;
|
||||
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
|
||||
@@ -92,7 +101,7 @@ export class Ctl extends lib.Link {
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
await (this.connector as CtlConnector).disconnect();
|
||||
await (this.connector as ControlConnector).disconnect();
|
||||
} catch (err) {
|
||||
if (!(err instanceof lib.SessionLost)) {
|
||||
throw err;
|
||||
@@ -101,23 +110,27 @@ export class Ctl extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
|
||||
async function loadPlugins(pluginList: Map<string, string>) {
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
|
||||
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
|
||||
let ctlPlugins = new Map<string, BaseCtlPlugin>();
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (!pluginInfo.ctlEntrypoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const context = { hooks, logger, plugin: pluginInfo };
|
||||
await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
|
||||
ctlPlugins.add(pluginInfo);
|
||||
let CtlPlugin = await lib.loadPluginClass(
|
||||
pluginInfo.name,
|
||||
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
|
||||
"CtlPlugin",
|
||||
BaseCtlPlugin,
|
||||
);
|
||||
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
|
||||
ctlPlugins.set(pluginInfo.name, ctlPlugin);
|
||||
await ctlPlugin.init();
|
||||
}
|
||||
|
||||
return ctlPlugins;
|
||||
}
|
||||
|
||||
@@ -128,34 +141,22 @@ interface CtlArguments {
|
||||
logLevel: keyof typeof levels,
|
||||
config: string,
|
||||
pluginList: string,
|
||||
bypassLockFile: boolean,
|
||||
createConfig: boolean,
|
||||
}
|
||||
|
||||
interface InitializeParameters {
|
||||
args: CtlArguments;
|
||||
shouldRun: boolean;
|
||||
ctlHooks: CtlHooks;
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>;
|
||||
rootCommands?: lib.CommandTree;
|
||||
ctlConfig?: lib.CtlConfig;
|
||||
controlConfig?: lib.ControlConfig;
|
||||
}
|
||||
|
||||
export async function initialize(
|
||||
argv: string | string[],
|
||||
ctlHooks: CtlHooks = new CtlHooks(logger),
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>,
|
||||
noLoggerTransport?: boolean,
|
||||
): Promise<InitializeParameters> {
|
||||
// Build a fresh, isolated yargs parser each time this function is called.
|
||||
// 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", {
|
||||
@@ -168,32 +169,25 @@ export async function initialize(
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-ctl.json",
|
||||
default: "config-control.json",
|
||||
defaultDescription: "auto",
|
||||
type: "string",
|
||||
})
|
||||
.option("create-config", {
|
||||
nargs: 0,
|
||||
describe: "Create config file if it does not exist",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
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 ctl config", lib.configCommand)
|
||||
.wrap(parser.terminalWidth())
|
||||
.command("control-config", "Manage Control config", lib.configCommand)
|
||||
.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) {
|
||||
@@ -207,47 +201,57 @@ export async function initialize(
|
||||
lib.handleUnhandledErrors();
|
||||
}
|
||||
|
||||
// Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
|
||||
if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
|
||||
// Discover and load plugins. This check exists to allow tests to inject plugins.
|
||||
if (!ctlPlugins || args._[0] === "plugin") {
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
const pluginList = await lib.loadPluginList(args.pluginList);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return { args, ctlHooks, shouldRun: false };
|
||||
return { args, shouldRun: false };
|
||||
}
|
||||
|
||||
logger.verbose("Loading Plugins");
|
||||
await loadPlugins(pluginList, ctlHooks);
|
||||
ctlPlugins = await loadPlugins(pluginList);
|
||||
}
|
||||
|
||||
// Add all commands including from plugins and reparse with help and strict checking.
|
||||
const rootCommands = await commands.registerCommands(ctlHooks, parser);
|
||||
args = parser
|
||||
const rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
args = yargs
|
||||
.help()
|
||||
.strict()
|
||||
.parse(argv) as CtlArguments
|
||||
;
|
||||
|
||||
const [ctlConfig, ctlConfigLock] = await lib.loadConfigFromArgs(
|
||||
args,
|
||||
lib.CtlConfig,
|
||||
"control",
|
||||
);
|
||||
let controlConfig;
|
||||
const controlConfigPath = args.config;
|
||||
logger.verbose(`Loading config from ${controlConfigPath}`);
|
||||
try {
|
||||
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.verbose("Config not found, initializing new config");
|
||||
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
|
||||
|
||||
} else {
|
||||
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
parser.showHelp();
|
||||
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
yargs.showHelp();
|
||||
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
}
|
||||
|
||||
// Handle the config command before trying to connect.
|
||||
if (args._[0] === "config") {
|
||||
await lib.handleConfigCommand(args, ctlConfig, ctlConfigLock);
|
||||
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: false };
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await lib.handleConfigCommand(args, controlConfig);
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
|
||||
}
|
||||
|
||||
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: true };
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
|
||||
}
|
||||
|
||||
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
|
||||
@@ -260,30 +264,38 @@ export function selectTargetCommand(args: CtlArguments, rootCommands: lib.Comman
|
||||
return targetCommand;
|
||||
}
|
||||
|
||||
async function startCtl() {
|
||||
async function startControl() {
|
||||
const {
|
||||
args,
|
||||
shouldRun,
|
||||
ctlPlugins,
|
||||
rootCommands,
|
||||
ctlConfig,
|
||||
controlConfig,
|
||||
} = await initialize(process.argv.slice(2));
|
||||
if (!shouldRun || !rootCommands || !ctlConfig) {
|
||||
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctlConfig.get("ctl.controller_url") || !ctlConfig.get("ctl.controller_token")) {
|
||||
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let controlConnector = new CtlConnector(
|
||||
ctlConfig.get("ctl.controller_url")!,
|
||||
ctlConfig.get("ctl.max_reconnect_delay"),
|
||||
ctlConfig.get("ctl.controller_token")!,
|
||||
let 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 ctl = new Ctl(controlConnector, ctlConfig);
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
@@ -293,16 +305,22 @@ async function startCtl() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", ctl.shutdown.bind(ctl)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", ctl.shutdown.bind(ctl)));
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const targetCommand = selectTargetCommand(args, rootCommands);
|
||||
await targetCommand.run(args, ctl);
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
ctl.keepOpen = false;
|
||||
control.keepOpen = false;
|
||||
if (err instanceof lib.CommandError) {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
@@ -320,23 +338,31 @@ async function startCtl() {
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!ctl.keepOpen) {
|
||||
await ctl.shutdown();
|
||||
if (!control.keepOpen) {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bootstrap() {
|
||||
startCtl().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (err.errors) {
|
||||
logger.fatal(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
if (!(err instanceof lib.StartupError)) {
|
||||
logger.fatal(`
|
||||
+---------------------------------------------------------------+
|
||||
| Unexpected error occured while starting clussterioctl, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+---------------------------------------------------------------+
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else {
|
||||
@@ -352,6 +378,6 @@ ${err.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { bootstrap } from "./ctl.js";
|
||||
export * from "./ctl.js";
|
||||
export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
|
||||
import { bootstrap } from "./ctl";
|
||||
export * from "./ctl";
|
||||
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
|
||||
|
||||
if (import.meta.main) {
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
}
|
||||
+21
-29
@@ -1,28 +1,9 @@
|
||||
{
|
||||
"name": "@clusterio/ctl",
|
||||
"version": "2.0.1",
|
||||
"description": "Clusterio command line management tool",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -31,19 +12,30 @@
|
||||
"bin": {
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"as-table": "^1.0.55",
|
||||
"winston": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
"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/node": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type CtlPluginContext = lib.PluginLoadContext<{
|
||||
hooks: CtlHooks
|
||||
}>;
|
||||
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
|
||||
|
||||
/**
|
||||
* Collection of clusterioctl plugin hooks
|
||||
* Base class for clusterioctl plugins
|
||||
*
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
export class CtlHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger: lib.Logger) {
|
||||
super(logger);
|
||||
this.addCommands = this.newHook();
|
||||
export default class BaseCtlPlugin {
|
||||
/**
|
||||
* Logger for this plugin
|
||||
*
|
||||
* Instance of winston Logger for sending log messages from this
|
||||
* plugin. Supported methods and their corresponding log levels are
|
||||
* `error`, `warn`, `audit`, `info` and `verbose`.
|
||||
*/
|
||||
logger: Logger;
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
public info: PluginNodeEnvInfo,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called to add commands to the command line interface.
|
||||
*
|
||||
@@ -23,47 +43,5 @@ export class CtlHooks extends lib.AsyncHookCollection {
|
||||
* @param rootCommand -
|
||||
* Root of the clusterioctl command tree.
|
||||
*/
|
||||
readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface BaseCtlPlugin {
|
||||
addCommands?(rootCommand: lib.CommandTree): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for clusterioctl plugins
|
||||
*
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class BaseCtlPlugin {
|
||||
constructor(
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
public info: lib.PluginNodeEnvInfo,
|
||||
public logger: lib.Logger,
|
||||
private hooks: CtlHooks,
|
||||
) {
|
||||
if (this.addCommands) {
|
||||
hooks.addCommands.attach(info.name, this.addCommands.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
static fromContext(context: CtlPluginContext) {
|
||||
return new this(context.plugin, context.logger, context.hooks);
|
||||
}
|
||||
|
||||
detachHooks() {
|
||||
this.hooks.detachAll(this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() {}
|
||||
async addCommands(rootCommand: CommandTree) { }
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export function print(...content: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...content);
|
||||
}
|
||||
+2150
-13
File diff suppressed because it is too large.
Load diff
@@ -1,210 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["stop", "Stop controller"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerStopRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["restart", "Restart controller"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
},
|
||||
}));
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["mark-restart-required", "Mark the controller as requiring a restart"],
|
||||
handler: async function(_args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.ControllerMarkRestartRequiredRequest());
|
||||
},
|
||||
}));
|
||||
controllerCommands.add(new lib.Command({
|
||||
definition: ["update", "Update the controller", (yargs) => {
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
|
||||
}],
|
||||
handler: async function(args: { restart: boolean, all: boolean }, ctl: Ctl) {
|
||||
if (args.all) {
|
||||
await ctl.send(new lib.UpdateAllRequest());
|
||||
} else {
|
||||
await ctl.send(new lib.ControllerUpdateRequest());
|
||||
}
|
||||
if (args.restart) {
|
||||
await ctl.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, ctl: Ctl) {
|
||||
let config = await ctl.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 }, ctl: Ctl) {
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
|
||||
},
|
||||
}));
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
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 ctl.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 }, ctl: Ctl) {
|
||||
let config = await ctl.send(new lib.ControllerConfigGetRequest());
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl controller config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.ControllerConfig);
|
||||
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 ctl.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, ctl: Ctl) {
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/plugins";
|
||||
let response = await fetch(url);
|
||||
print(asTable(await response.json() as []));
|
||||
},
|
||||
}));
|
||||
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 }, ctl: Ctl) {
|
||||
await ctl.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
await ctl.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
print("Plugin installed; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
controllerCommands.add(controllerPluginCommands);
|
||||
@@ -1,31 +0,0 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
|
||||
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
await ctl.send(new lib.DebugDumpWsRequest());
|
||||
ctl.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"parse-exchange-string <exchange-string>",
|
||||
"Parse and print the contents of an exchange string",
|
||||
(yargs) => {
|
||||
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
|
||||
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
|
||||
}],
|
||||
handler: async function(args: { exchangeString: string, pretty: boolean }, ctl: Ctl) {
|
||||
const result = lib.readMapExchangeString(args.exchangeString);
|
||||
if (args.pretty) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,310 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const 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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: ["mark-restart-required <host>", "Mark the given host as requiring a restart", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to mark", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { host: string }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.HostMarkRestartRequiredRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
hostCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List hosts connected to the controller"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let hosts = await ctl.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 }, ctl: Ctl) {
|
||||
let hostId = typeof args.id === "number" ? args.id : undefined;
|
||||
let response = await ctl.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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.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 },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let config = await ctl.send(
|
||||
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
|
||||
);
|
||||
|
||||
let content = JSON.stringify(config, null, "\t");
|
||||
if (args.output === "-") {
|
||||
print(content);
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
|
||||
} 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" });
|
||||
yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
|
||||
}],
|
||||
handler: async function(args: { host: string, restart: boolean, all: boolean }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
if (args.all) {
|
||||
await ctl.sendTo({ hostId }, new lib.UpdateAllRequest());
|
||||
} else {
|
||||
await ctl.sendTo({ hostId }, new lib.HostUpdateRequest());
|
||||
}
|
||||
if (args.restart) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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 },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.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 },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let hostId = await lib.resolveHost(ctl, 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 ctl.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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
let config = await ctl.sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl host config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.HostConfig);
|
||||
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 ctl.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
|
||||
print(asTable(plugins.map(p => ({
|
||||
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
await ctl.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
|
||||
if (args.restart) {
|
||||
await ctl.sendTo({ hostId }, new lib.HostRestartRequest());
|
||||
} else {
|
||||
print("Plugin installed; a restart is required to apply the changes.");
|
||||
}
|
||||
},
|
||||
}));
|
||||
hostCommands.add(hostPluginCommands);
|
||||
@@ -1,661 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import child_process from "child_process";
|
||||
import stream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
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, ctl: Ctl) {
|
||||
let list = await ctl.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, }, ctl: Ctl) {
|
||||
let instanceConfig = new lib.InstanceConfig("control");
|
||||
if (args.id !== undefined) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
|
||||
for (let [name, value] of Object.entries(config)) {
|
||||
print(`${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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 },
|
||||
ctl: Ctl,
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
if (args.stdin) {
|
||||
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||
} else if (args.value === undefined) {
|
||||
args.value = "";
|
||||
}
|
||||
await ctl.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 },
|
||||
ctl: Ctl
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, 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 ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let config = await ctl.send(new lib.InstanceConfigGetRequest(instanceId));
|
||||
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
|
||||
let editor = await getEditor(args.editor);
|
||||
if (editor === undefined) {
|
||||
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
|
||||
Try "ctl instance config edit <editor of choice>"`);
|
||||
}
|
||||
let allConfigElements = serializedConfigToString(config, lib.InstanceConfig);
|
||||
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 ctl.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let hostId = args.host ? await lib.resolveHost(ctl, args.host) : undefined;
|
||||
await ctl.send(new lib.InstanceAssignRequest(instanceId, hostId));
|
||||
},
|
||||
}));
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let saves = await ctl.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
|
||||
saves = saves.filter(save => save.instanceId === instanceId);
|
||||
print(asTable(saves.map(
|
||||
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
|
||||
)));
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let filename = args.name || path.basename(args.filepath);
|
||||
if (!filename.endsWith(".zip")) {
|
||||
throw new lib.CommandError("Save name must end with .zip");
|
||||
}
|
||||
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/upload-save";
|
||||
url.searchParams.append("instance_id", String(instanceId));
|
||||
url.searchParams.append("filename", filename);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: (await fs.open(args.filepath)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": ctl.config.get("ctl.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,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let sourceInstanceId = await lib.resolveInstance(ctl, args.sourceInstance);
|
||||
let targetInstanceId = await lib.resolveInstance(ctl, args.targetInstance);
|
||||
let storedName = await ctl.send(
|
||||
new lib.InstanceTransferSaveRequest(
|
||||
sourceInstanceId,
|
||||
args.sourceSave,
|
||||
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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
let streamId = await ctl.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
const filename = await lib.downloadFile(url, args.save, "rename");
|
||||
|
||||
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
|
||||
},
|
||||
}));
|
||||
|
||||
instanceSaveCommands.add(new lib.Command({
|
||||
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
|
||||
yargs.positional("save", { describe: "Save to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string, save: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["save-game <instance>", "Save the running game of an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to save the game on", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceSaveGameRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { instance: string }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
const instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
|
||||
ctl.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(new lib.Command({
|
||||
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 }, ctl: Ctl) {
|
||||
const instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
|
||||
ctl.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
// Get all instances
|
||||
const instances = await ctl.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 ctl.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,
|
||||
},
|
||||
ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
|
||||
await ctl.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
|
||||
);
|
||||
ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.sendTo({ instanceId }, new lib.InstanceStopRequest());
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["stop-all", "Stop all running instances", () => {}],
|
||||
handler: async function(_args, ctl: Ctl) {
|
||||
// Get all instances
|
||||
const instances = await ctl.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 ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let instanceId = await lib.resolveInstance(ctl, args.instance);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let result = await ctl.sendTo(
|
||||
{ instanceId: await lib.resolveInstance(ctl, args.instance) },
|
||||
new lib.InstanceSendRconRequest(args.command),
|
||||
);
|
||||
|
||||
// Factorio includes a newline in its response output.
|
||||
process.stdout.write(result);
|
||||
},
|
||||
}));
|
||||
@@ -1,90 +0,0 @@
|
||||
import winston from "winston";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
|
||||
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 },
|
||||
ctl: Ctl
|
||||
) {
|
||||
if (!args.all && !args.controller && !args.host && !args.instance) {
|
||||
logger.error("At least one of --all, --controller, --host and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
|
||||
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
|
||||
await ctl.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
|
||||
ctl.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
if (!args.all && !args.controller && !args.host && !args.instance) {
|
||||
logger.error("At least one of --all, --controller, --host and --instance must be passed");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let instanceIds = args.instance ? [await lib.resolveInstance(ctl, args.instance)] : [];
|
||||
let hostIds = args.host ? [await lib.resolveHost(ctl, args.host)] : [];
|
||||
let result = await ctl.send(new lib.LogQueryRequest(
|
||||
args.all,
|
||||
args.controller,
|
||||
hostIds,
|
||||
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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,219 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
import util from "util";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let modInfo = await ctl.send(new lib.ModGetRequest(args.name, args.modVersion));
|
||||
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[] }, ctl: Ctl) {
|
||||
let mods = await ctl.send(new lib.ModListRequest());
|
||||
if (!args.fields.includes("all")) {
|
||||
for (let entry of mods) {
|
||||
for (let field of Object.keys(entry)) {
|
||||
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[],
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
|
||||
if (searchVersion === undefined) {
|
||||
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
|
||||
}
|
||||
let response = await ctl.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
lib.normaliseMajorMinorVersion(searchVersion),
|
||||
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 }, ctl: Ctl) {
|
||||
let filename = path.basename(args.file);
|
||||
if (!filename.endsWith(".zip")) {
|
||||
throw new lib.CommandError("Mod filename must end with .zip");
|
||||
}
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += "api/upload-mod";
|
||||
url.searchParams.append("filename", filename);
|
||||
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: (await fs.open(args.file)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": ctl.config.get("ctl.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 }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let streamId = await ctl.send(new lib.ModDownloadRequest(args.name, args.modVersion));
|
||||
|
||||
let url = new URL(ctl.config.get("ctl.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let filename = `${args.name}_${args.modVersion}.zip`;
|
||||
await lib.downloadFile(url, filename, "overwrite");
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await ctl.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
},
|
||||
}));
|
||||
@@ -1,315 +0,0 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
let mods = new Map(
|
||||
(await ctl.send(new lib.ModListRequest())).map(m => [m.id, m])
|
||||
);
|
||||
let modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, 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, ctl: Ctl) {
|
||||
let modPacks = await ctl.send(new lib.ModPackListRequest());
|
||||
let fields = ["id", "name", "factorioVersion"];
|
||||
for (let entry of modPacks) {
|
||||
for (let field of Object.keys(entry)) {
|
||||
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.isSourceVersion(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[],
|
||||
},
|
||||
ctl: Ctl,
|
||||
) {
|
||||
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 ctl.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 }, ctl: Ctl) {
|
||||
const modPack = lib.ModPack.fromModPackString(args.string);
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
const modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, 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[],
|
||||
},
|
||||
ctl: Ctl,
|
||||
) {
|
||||
const modPack = await ctl.send(
|
||||
new lib.ModPackGetRequest(await lib.resolveModPack(ctl, 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 ctl.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 }, ctl: Ctl) {
|
||||
const id = await lib.resolveModPack(ctl, args.modPack);
|
||||
await ctl.send(new lib.ModPackDeleteRequest(id));
|
||||
},
|
||||
}));
|
||||
@@ -1,16 +0,0 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
|
||||
permissionCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List permissions in the cluster"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let permissions = await ctl.send(new lib.PermissionListRequest());
|
||||
print(asTable(permissions));
|
||||
},
|
||||
}));
|
||||
@@ -1,100 +0,0 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
|
||||
roleCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List roles in the cluster"],
|
||||
handler: async function(args: object, ctl: Ctl) {
|
||||
let roles = await ctl.send(new lib.RoleListRequest());
|
||||
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[] }, ctl: Ctl) {
|
||||
let id = await ctl.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,
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
let role = await lib.retrieveRole(ctl, 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 ctl.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
|
||||
|
||||
if (args.grantDefault) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
let role = await lib.retrieveRole(ctl, args.role);
|
||||
await ctl.send(new lib.RoleDeleteRequest(role.id));
|
||||
},
|
||||
}));
|
||||
@@ -1,304 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import path from "path";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
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 }, ctl: Ctl) {
|
||||
let user = await ctl.send(new lib.UserGetRequest(args.name));
|
||||
Object.assign(user, user.playerStats);
|
||||
// @ts-expect-error Terrible hack
|
||||
delete user.playerStats;
|
||||
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 ctl.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 }, ctl: Ctl) {
|
||||
let users = await ctl.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 }, ctl: Ctl) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
await ctl.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 }, ctl: Ctl) {
|
||||
await ctl.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
|
||||
},
|
||||
ctl: Ctl
|
||||
) {
|
||||
await ctl.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[] }, ctl: Ctl) {
|
||||
let roles = await ctl.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 ctl.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 }, ctl: Ctl) {
|
||||
await ctl.send(new lib.UserDeleteRequest(args.user));
|
||||
},
|
||||
}));
|
||||
|
||||
async function handleImportOrRestore(args: {
|
||||
filepath: string,
|
||||
users: boolean,
|
||||
bans: boolean,
|
||||
admins: boolean,
|
||||
whitelist: boolean,
|
||||
noBackup?: boolean,
|
||||
}, ctl: Ctl, restore: boolean) {
|
||||
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
|
||||
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
|
||||
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
|
||||
.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 ctl.send(
|
||||
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
|
||||
);
|
||||
|
||||
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
|
||||
if (restore && backup && !args.noBackup) {
|
||||
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
|
||||
print(`Wrote backup to ${importType}-backup.json`);
|
||||
}
|
||||
}
|
||||
|
||||
userCommands.add(new lib.Command({
|
||||
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
|
||||
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
|
||||
yargs.options({
|
||||
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
|
||||
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
|
||||
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
|
||||
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: any, ctl: any) {
|
||||
await handleImportOrRestore(args, ctl, 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, ctl: any) {
|
||||
await handleImportOrRestore(args, ctl, 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,
|
||||
}, ctl: Ctl) {
|
||||
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
|
||||
const optionCount = [args.bans, args.admins, args.whitelist]
|
||||
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
|
||||
|
||||
if (optionCount > 1) {
|
||||
throw new lib.CommandError("Can not specify multiple options");
|
||||
}
|
||||
|
||||
// Assign based on options or attempt to guess based on filename
|
||||
if (args.users) {
|
||||
exportType = "users";
|
||||
} else if (args.bans) {
|
||||
exportType = "bans";
|
||||
} else if (args.admins) {
|
||||
exportType = "admins";
|
||||
} else if (args.whitelist) {
|
||||
exportType = "whitelist";
|
||||
}
|
||||
|
||||
await fs.writeFile(args.filepath, JSON.stringify(await ctl.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), undefined, "\t"));
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
},
|
||||
}));
|
||||
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user