mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24e290746d | ||
|
|
d52931a326 | ||
|
|
73cc019d2f | ||
|
|
41151d47f0 | ||
|
|
3fbd9a995d | ||
|
|
af299bc08d | ||
|
|
93fa12200f | ||
|
|
35f769ad25 | ||
|
|
732cee4837 | ||
|
|
681e78967f | ||
|
|
d2bd1382ba | ||
|
|
c0ec9a3890 | ||
|
|
4221b96431 | ||
|
|
3ddbe2ddb3 | ||
|
|
7ceb786143 | ||
|
|
174d9d5b07 | ||
|
|
a1118d08dd | ||
|
|
379d2575a0 | ||
|
|
e30dcc6fff | ||
|
|
281a5ed97e | ||
|
|
6004f861fe | ||
|
|
174c798d4b | ||
|
|
14b8b5ac61 | ||
|
|
89dec572bb | ||
|
|
f662880c8d | ||
|
|
90cd2770b3 | ||
|
|
69240e7917 | ||
|
|
e1d9c1e414 | ||
|
|
376eaeba52 | ||
|
|
80983662aa | ||
|
|
9840f22688 | ||
|
|
b9e2acaaa5 | ||
|
|
7ac8cd3781 | ||
|
|
7e2f10e842 | ||
|
|
256407b9fd | ||
|
|
7979dfaa16 | ||
|
|
10a87d47da | ||
|
|
59709280da | ||
|
|
81b656f0ab | ||
|
|
f966c5b965 | ||
|
|
56a26848e6 | ||
|
|
28f21bfb70 | ||
|
|
7b4724845f | ||
|
|
2053423864 | ||
|
|
e7519975ec | ||
|
|
7b7389526b | ||
|
|
a464b61d39 | ||
|
|
88ca4563a8 | ||
|
|
449bd68fab | ||
|
|
59256e77d1 | ||
|
|
f11d91bd6b | ||
|
|
e0278d0331 | ||
|
|
2baa2e50ac | ||
|
|
02d14bd98d | ||
|
|
952ae21958 | ||
|
|
50d7d28054 | ||
|
|
2896ec1c15 | ||
|
|
6b81e6383d | ||
|
|
5ed7163ed8 | ||
|
|
df1edec623 | ||
|
|
a5bf8d92d9 | ||
|
|
af95ca587b | ||
|
|
331fc7d947 |
No files matched your search
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
factorio:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
env:
|
||||
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./dist/*
|
||||
@@ -31,6 +31,50 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.22
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
|
||||
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
|
||||
|
||||
### Features
|
||||
|
||||
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
|
||||
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
|
||||
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
|
||||
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
|
||||
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
### Changes
|
||||
|
||||
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
|
||||
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
|
||||
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
|
||||
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
|
||||
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
|
||||
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
|
||||
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
|
||||
|
||||
### 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
|
||||
|
||||
@@ -47,6 +47,9 @@ async function removeBuildArtifacts() {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +84,10 @@ async function removeExecutionArtifacts() {
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs
|
||||
await yargs(process.argv.slice(2))
|
||||
.scriptName("clean")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("dry", {
|
||||
.option("dry-run", {
|
||||
alias: "dry",
|
||||
nargs: 0,
|
||||
describe: "Will not delete any files",
|
||||
default: false,
|
||||
@@ -105,7 +108,7 @@ async function main() {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.demandCommand(1, "A command must be specified")
|
||||
.strict()
|
||||
.parse();
|
||||
}
|
||||
|
||||
@@ -239,6 +239,14 @@ Does not affect start or stop of the instance while the host is running.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
This allows you to prevent certain instances from being started when using the bulk start operation.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
|
||||
+275
-186
@@ -1,118 +1,47 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"node",
|
||||
],
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es2022": true,
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["{packages,plugins}/**/*.ts?(x)"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"parserOptions": { sourceType: "commonjs" },
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "off",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{t,j}s?(x)",
|
||||
],
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
@@ -120,26 +49,34 @@ module.exports = {
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "error",
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
@@ -148,27 +85,36 @@ module.exports = {
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||
|
||||
"dot-notation": ["error", {
|
||||
"allowPattern": "^[a-z]+(_[a-z]+)*$",
|
||||
}],
|
||||
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
|
||||
"func-style": ["error", "declaration", {
|
||||
// Allow arrow functions as it better expresses referencing this
|
||||
// of the containing scope, which is unweilding in plain functions.
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
@@ -178,10 +124,18 @@ module.exports = {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
|
||||
"lines-between-class-members": ["error", "always", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
}],
|
||||
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
@@ -190,10 +144,12 @@ module.exports = {
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
@@ -207,7 +163,11 @@ module.exports = {
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
|
||||
"no-constant-condition": ["error", {
|
||||
"checkLoops": false,
|
||||
}],
|
||||
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
@@ -232,16 +192,15 @@ module.exports = {
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
@@ -256,7 +215,11 @@ module.exports = {
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
@@ -268,9 +231,15 @@ module.exports = {
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
@@ -289,7 +258,11 @@ module.exports = {
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
@@ -299,6 +272,7 @@ module.exports = {
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
@@ -308,6 +282,7 @@ module.exports = {
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
@@ -324,9 +299,13 @@ module.exports = {
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
@@ -342,73 +321,183 @@ module.exports = {
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
"strict": "error",
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
"valid-jsdoc": ["error", {
|
||||
"requireReturn": false,
|
||||
"requireReturnType": false,
|
||||
"requireParamType": false,
|
||||
}],
|
||||
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": ["error", "never", { "exceptRange": true }],
|
||||
|
||||
"yoda": ["error", "never", {
|
||||
"exceptRange": true,
|
||||
}],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
// Node.js callback passing style of (err, value) is not used in this project
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": ["error", {
|
||||
"tryExtensions": [".js", ".ts"],
|
||||
}],
|
||||
"node/no-missing-require": "error",
|
||||
"node/no-mixed-requires": "error",
|
||||
"node/no-new-require": "error",
|
||||
"node/no-path-concat": "error",
|
||||
"node/no-process-env": "error",
|
||||
"node/no-process-exit": "error",
|
||||
|
||||
"node/no-missing-import": "off",
|
||||
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "error",
|
||||
"node/no-unpublished-import": "error",
|
||||
"node/no-unpublished-require": [
|
||||
"error",
|
||||
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
|
||||
],
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
|
||||
"node/no-unpublished-require": "off",
|
||||
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "error",
|
||||
"node/prefer-global/console": "error",
|
||||
"node/prefer-global/process": "error",
|
||||
"node/prefer-global/text-decoder": "error",
|
||||
"node/prefer-global/text-encoder": "error",
|
||||
"node/prefer-global/url-search-params": "error",
|
||||
"node/prefer-global/url": "error",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": ["error", {
|
||||
"convertPath": {
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
|
||||
},
|
||||
}],
|
||||
"node/process-exit-as-throw": "off",
|
||||
|
||||
"node/shebang": "off",
|
||||
},
|
||||
};
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "commonjs",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
+19
-15
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
|
||||
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
|
||||
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
|
||||
"silent-test": "SILENT_TEST=y pnpm test",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
@@ -41,21 +41,25 @@
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@stylistic/eslint-plugin": "^5.2.2",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^4.21.2",
|
||||
"form-data": "^4.0.2",
|
||||
"express": "^5.1.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.0",
|
||||
"mocha": "^10.8.2",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.7.3",
|
||||
"yargs": "^17.7.2"
|
||||
"globals": "^16.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"mocha": "^11.7.1",
|
||||
"nyc": "^17.1.0",
|
||||
"phin": "^3.7.1",
|
||||
"typedoc": "^0.28.9",
|
||||
"typescript": "^5.9.2",
|
||||
"yargs": "^18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -130,8 +130,16 @@ async function handleBootstrapCommand(
|
||||
controllerConfig: lib.ControllerConfig
|
||||
): Promise<void> {
|
||||
let subCommand = args._[1];
|
||||
let userManager = new UserManager(controllerConfig);
|
||||
await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
|
||||
const databaseDirectory = controllerConfig.get("controller.database_directory");
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(controllerConfig, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
|
||||
if (subCommand === "create-admin") {
|
||||
if (!args.name) {
|
||||
logger.error("name cannot be blank");
|
||||
@@ -144,7 +152,7 @@ async function handleBootstrapCommand(
|
||||
admin = userManager.createUser(args.name);
|
||||
}
|
||||
|
||||
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
|
||||
let adminRole = lib.ensureDefaultAdminRole(roles);
|
||||
admin.roleIds.add(adminRole.id);
|
||||
admin.isAdmin = true;
|
||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||
@@ -414,7 +422,7 @@ async function startup() {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -427,7 +435,7 @@ async function startup() {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as routes from "./routes";
|
||||
* @alias module:controller/src/BaseConnection
|
||||
*/
|
||||
export default class BaseConnection extends lib.Link {
|
||||
declare ["connector"]: WsServerConnector;
|
||||
declare "connector": WsServerConnector;
|
||||
|
||||
constructor(
|
||||
connector: lib.WebSocketBaseConnector,
|
||||
@@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: string, sha1?: string }) {
|
||||
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
|
||||
@@ -100,6 +100,8 @@ 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));
|
||||
@@ -124,7 +126,6 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -313,7 +314,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = name;
|
||||
|
||||
let ready = new Promise((resolve, reject) => {
|
||||
let ready = new Promise<void>((resolve, reject) => {
|
||||
stream.events.on("source", resolve);
|
||||
stream.events.on("timeout", () => reject(
|
||||
new lib.RequestError("Timed out establishing stream from host")
|
||||
@@ -564,52 +565,181 @@ 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): Promise<lib.ModInfo> {
|
||||
const { name, version, factorioVersion } = request;
|
||||
const filename = lib.ModInfo.filename(name, version);
|
||||
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
|
||||
const factorioVersion = request.factorioVersion;
|
||||
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
|
||||
|
||||
// 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)!;
|
||||
}
|
||||
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");
|
||||
|
||||
// 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.");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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.`);
|
||||
if (!username || username === "" || !token || token === "") {
|
||||
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
logger.info(`Successfully downloaded mod ${filename}.`);
|
||||
return downloadedMod;
|
||||
|
||||
} 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.";
|
||||
try {
|
||||
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
|
||||
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mods from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = "Mod portal download failed";
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all mods are present
|
||||
const modInfos = [];
|
||||
const installedMods = [...this._controller.modStore.mods()];
|
||||
for (const mod of request.mods) {
|
||||
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
}
|
||||
modInfos.push(modInfo);
|
||||
}
|
||||
|
||||
return modInfos;
|
||||
}
|
||||
|
||||
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
|
||||
const {factorioVersion, checkForUpdates} = request;
|
||||
const searchQueue = [...request.mods];
|
||||
const localInfos = [...this._controller.modStore.mods()];
|
||||
|
||||
const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
|
||||
const builtInModNames = new Set(_builtInModNames);
|
||||
const skipSearch = new Set(_builtInModNames);
|
||||
|
||||
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
||||
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
|
||||
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const optionalRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const candidateReleases = new Map<string, lib.ModInfo>();
|
||||
const incompatible = new Set<string>();
|
||||
const invalid = new Set<string>();
|
||||
|
||||
const _modPortalReleases = new Map<string, ModPortalModType | null>();
|
||||
async function fetchModReleases(modName: string) {
|
||||
let releases = _modPortalReleases.get(modName);
|
||||
if (releases === undefined) {
|
||||
try {
|
||||
releases = await lib.ModStore.fetchModReleases(modName);
|
||||
} catch (error: any) {
|
||||
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
||||
logger.error(`Error fetching mod from portal: ${error.message}`);
|
||||
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
|
||||
} else {
|
||||
releases = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_modPortalReleases.set(modName, releases);
|
||||
return releases;
|
||||
}
|
||||
|
||||
while (searchQueue.length > 0) {
|
||||
const mod = searchQueue.shift()!;
|
||||
if (mod.incompatible) {
|
||||
incompatible.add(mod.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the current version range (optionals are tracked in case they become required)
|
||||
let versionRange = dependencyRequirements.get(mod.name);
|
||||
if (!versionRange) {
|
||||
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
|
||||
if (mod.required) {
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
optionalRequirements.delete(mod.name);
|
||||
} else {
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the version range if needed
|
||||
if (mod.version) {
|
||||
versionRange.combineVersion(mod.version);
|
||||
if (!versionRange.valid) {
|
||||
candidateReleases.delete(mod.name);
|
||||
invalid.add(mod.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Do not add dependencies for invalids, optionals, skips, or valid candidates
|
||||
const existingCandidate = candidateReleases.get(mod.name);
|
||||
if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || (
|
||||
existingCandidate && versionRange.testVersion(existingCandidate.version)
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if a local version fits the requirements
|
||||
let candidate = localInfos
|
||||
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
|
||||
.reduce<lib.ModInfo | undefined>((max, cur) => (
|
||||
max && max.integerVersion > cur.integerVersion ? max : cur
|
||||
), undefined);
|
||||
|
||||
// Check if a mod poral version fits the requirements
|
||||
if (!candidate || checkForUpdates) {
|
||||
const modReleases = await fetchModReleases(mod.name);
|
||||
if (modReleases && modReleases.releases) {
|
||||
const release = modReleases.releases
|
||||
.filter(info => (
|
||||
info.info_json.factorio_version === factorioVersion
|
||||
&& versionRange.testVersion(info.version)
|
||||
))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
// We only select the mod portal release if it is more recent than the local candidate
|
||||
if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) {
|
||||
candidate = lib.ModInfo.fromJSON({
|
||||
...release.info_json, // Also includes dependences
|
||||
sha1: release.sha1,
|
||||
version: release.version,
|
||||
name: modReleases.name,
|
||||
author: modReleases.owner,
|
||||
title: modReleases.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue the dependencies of the new candidate
|
||||
if (candidate) {
|
||||
candidateReleases.set(mod.name, candidate);
|
||||
for (const dep of candidate.dependencies) {
|
||||
searchQueue.push(dep);
|
||||
}
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
candidateReleases.delete(mod.name);
|
||||
skipSearch.add(mod.name);
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
}
|
||||
|
||||
return new lib.ModDependencyResolveRequest.Response(
|
||||
[...candidateReleases.values()],
|
||||
[...invalid.values(), ...[...incompatible.values()].filter(mod => (
|
||||
candidateReleases.has(mod) || builtInModNames.has(mod)
|
||||
))],
|
||||
[...dependencyRequirements.keys()].filter(mod => (
|
||||
!candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod)
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
@@ -671,22 +801,21 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
async handleRoleListRequest() {
|
||||
return [...this._controller.userManager.roles.values()];
|
||||
return [...this._controller.roles.values()];
|
||||
}
|
||||
|
||||
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
|
||||
let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]);
|
||||
let lastId = Math.max.apply(null, [...this._controller.roles.keys()]);
|
||||
|
||||
// Start at 5 to leave space for future default roles
|
||||
let id = Math.max(5, lastId+1);
|
||||
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
|
||||
this._controller.userManager.dirty = true;
|
||||
this._controller.roles.set(lib.Role.fromJSON({ id, ...request }));
|
||||
return id;
|
||||
}
|
||||
|
||||
async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) {
|
||||
let { id, name, description, permissions } = request;
|
||||
let role = this._controller.userManager.roles.get(id);
|
||||
let role = this._controller.roles.getMutable(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
@@ -694,27 +823,29 @@ export default class ControlConnection extends BaseConnection {
|
||||
role.name = name;
|
||||
role.description = description;
|
||||
role.permissions = new Set(permissions);
|
||||
this._controller.rolePermissionsUpdated(role);
|
||||
this._controller.roles.set(role);
|
||||
}
|
||||
|
||||
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
||||
let role = this._controller.userManager.roles.get(request.id);
|
||||
let role = this._controller.roles.get(request.id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
this._controller.rolePermissionsUpdated(role);
|
||||
this._controller.roles.set(role);
|
||||
}
|
||||
|
||||
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
|
||||
let id = request.id;
|
||||
|
||||
if (!this._controller.userManager.roles.delete(id)) {
|
||||
const role = this._controller.roles.get(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.userManager.dirty = true;
|
||||
this._controller.roles.delete(role);
|
||||
|
||||
this._controller.userManager.dirty = true;
|
||||
for (let user of this._controller.userManager.users.values()) {
|
||||
if (user.roleIds.delete(id)) {
|
||||
this._controller.userPermissionsUpdated(user);
|
||||
@@ -766,7 +897,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
for (let roleId of request.roles) {
|
||||
if (!this._controller.userManager.roles.has(roleId)) {
|
||||
if (!this._controller.roles.has(roleId)) {
|
||||
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
|
||||
}
|
||||
}
|
||||
@@ -900,7 +1031,7 @@ 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<ControllerUser["id"], ControllerUser>();
|
||||
@@ -1078,9 +1209,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this._controller.plugins.has(pluginInfo.name),
|
||||
this._controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
|
||||
) as boolean,
|
||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Static } from "@sinclair/typebox";
|
||||
import finalhandler from "finalhandler";
|
||||
|
||||
import compression from "compression";
|
||||
import events, { EventEmitter } from "events";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
@@ -42,6 +42,14 @@ const logSizeGauge = new Gauge(
|
||||
|
||||
type InstanceId = { instanceId: number };
|
||||
|
||||
type ControllerDebugEvents = {
|
||||
"message": [ { direction: "in" | "out", content: string } ],
|
||||
};
|
||||
|
||||
type ControllerEvents = {
|
||||
"stop": [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all controller related operations
|
||||
* @alias module:controller/src/Controller
|
||||
@@ -66,8 +74,8 @@ export default class Controller {
|
||||
wsServer: WsServer;
|
||||
router = new ControllerRouter(this);
|
||||
trustedProxies: BlockList;
|
||||
debugEvents: events.EventEmitter = new events.EventEmitter();
|
||||
private _events: events.EventEmitter = new events.EventEmitter();
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions = new lib.SubscriptionController();
|
||||
@@ -127,20 +135,32 @@ export default class Controller {
|
||||
|
||||
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "mod-packs.json"),
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack)
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack),
|
||||
this.migrateModPacks,
|
||||
).bootstrap());
|
||||
|
||||
if (modPacks.size === 0) {
|
||||
modPacks.setMany(lib.ModPack.defaultModPacks);
|
||||
}
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
this.migrateRoles,
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(config);
|
||||
const userManager = new UserManager(config, roles);
|
||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
|
||||
// Add default mod packs
|
||||
if (modPacks.size === 0) {
|
||||
modPacks.setMany(lib.ModPack.defaultModPacks);
|
||||
}
|
||||
|
||||
// Add default roles
|
||||
lib.ensureDefaultAdminRole(roles);
|
||||
lib.ensureDefaultPlayerRole(roles);
|
||||
|
||||
return [
|
||||
systems,
|
||||
hosts,
|
||||
@@ -148,6 +168,7 @@ export default class Controller {
|
||||
saves,
|
||||
modPacks,
|
||||
modStore,
|
||||
roles,
|
||||
userManager,
|
||||
] as const;
|
||||
}
|
||||
@@ -178,8 +199,10 @@ export default class Controller {
|
||||
public modPacks = new lib.SubscribableDatastore<lib.ModPack>(),
|
||||
/** Mods stored on the controller */
|
||||
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
|
||||
/** Mapping of mod pack id to mod pack */
|
||||
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
||||
/** User and roles manager for the cluster */
|
||||
public userManager = new UserManager(config),
|
||||
public userManager = new UserManager(config, roles),
|
||||
) {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
@@ -206,6 +229,7 @@ export default class Controller {
|
||||
this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
|
||||
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
@@ -213,6 +237,7 @@ export default class Controller {
|
||||
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
|
||||
this.saves.on("update", this.savesUpdated.bind(this));
|
||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||
this.roles.on("update", this.rolesUpdated.bind(this));
|
||||
}
|
||||
|
||||
async start(args: ControllerArgs) {
|
||||
@@ -375,13 +400,12 @@ export default class Controller {
|
||||
|
||||
async _startDevServer(args: ControllerArgs) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
const webpack = require("webpack");
|
||||
const webpackDevMiddleware = require("webpack-dev-middleware");
|
||||
const webpackConfigs = [];
|
||||
|
||||
if (args.dev) {
|
||||
// eslint-disable-next-line node/no-missing-require
|
||||
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
@@ -397,7 +421,7 @@ export default class Controller {
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-var-requires, node/no-unpublished-require */
|
||||
|
||||
|
||||
const compiler = webpack(webpackConfigs);
|
||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
||||
@@ -526,19 +550,8 @@ export default class Controller {
|
||||
}
|
||||
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
||||
}
|
||||
if (!this.config.restartRequired) {
|
||||
// If a restart isn't already required, then test if a new version is installed
|
||||
try {
|
||||
const runningVersion = this.config.get("controller.version");
|
||||
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
}
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired));
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired));
|
||||
const newMetrics = await Promise.all(requests);
|
||||
for (const metric of newMetrics) {
|
||||
this.systems.set(metric);
|
||||
@@ -548,6 +561,44 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let packageName = "@clusterio/controller";
|
||||
try {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("controller.version");
|
||||
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second check plugin versions
|
||||
for (const pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJson = await fs.readJSON(path.join(pluginInfo.requirePath, "package.json"));
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onAutosaveIntervalChanged() {
|
||||
if (this.autosaveInterval) {
|
||||
clearInterval(this.autosaveInterval);
|
||||
@@ -589,6 +640,7 @@ export default class Controller {
|
||||
this.instances.save(),
|
||||
this.saves.save(),
|
||||
this.modPacks.save(),
|
||||
this.roles.save(),
|
||||
]);
|
||||
|
||||
if (this.userManager.dirty) {
|
||||
@@ -649,6 +701,31 @@ export default class Controller {
|
||||
return instance;
|
||||
}
|
||||
|
||||
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
for (const mod of json.mods) {
|
||||
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
}
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
|
||||
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
json.permissions = json.permissions.map(permission => {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
if (permission === "core.instance.save.list.subscribe") {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
});
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
|
||||
let manifest = {};
|
||||
try {
|
||||
@@ -1052,27 +1129,34 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected control clients with the given role that the
|
||||
* permissions may have changed.
|
||||
* @param role - Role permisions updated for.
|
||||
*/
|
||||
rolePermissionsUpdated(role: lib.Role) {
|
||||
for (let controlConnection of this.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
controlConnection.send(
|
||||
new lib.AccountUpdateEvent(
|
||||
[...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
}))
|
||||
)
|
||||
);
|
||||
rolesUpdated(roles: lib.Role[]) {
|
||||
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
|
||||
// lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
|
||||
// Notify connected control clients with the given role that the permissions may have changed.
|
||||
for (const role of roles) {
|
||||
for (let controlConnection of this.wsServer.controlConnections.values()) {
|
||||
if (controlConnection.user.roles.has(role)) {
|
||||
controlConnection.send(
|
||||
new lib.AccountUpdateEvent(
|
||||
[...controlConnection.user.roles].map(r => ({
|
||||
name: r.name,
|
||||
id: r.id,
|
||||
permissions: [...r.permissions],
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleRoleSubscription(request: lib.SubscriptionRequest) {
|
||||
const roles = [...this.roles.values()].filter(
|
||||
role => role.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
try {
|
||||
@@ -1082,7 +1166,7 @@ export default class Controller {
|
||||
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
|
||||
}
|
||||
|
||||
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
|
||||
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
|
||||
import type UserManager from "./UserManager";
|
||||
|
||||
import {
|
||||
IControllerUser, PermissionError, PlayerStats,
|
||||
Role, SubscribableDatastore, User, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
export default class ControllerUser extends User implements IControllerUser {
|
||||
constructor(
|
||||
public userManager: UserManager,
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof User>
|
||||
@@ -17,7 +20,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
|
||||
const roleIds = new Set<number>(json.roles ?? []);
|
||||
const instanceStats = new Map(
|
||||
(json.instance_stats ?? []).map(
|
||||
@@ -26,7 +29,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
);
|
||||
const playerStats = User._calculatePlayerStats(instanceStats);
|
||||
return new this(
|
||||
userManager,
|
||||
controllerRoles,
|
||||
json.token_valid_after,
|
||||
json.name,
|
||||
roleIds,
|
||||
@@ -57,7 +60,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this.userManager.roles.get(id)
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
@@ -79,7 +82,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
}
|
||||
|
||||
for (let roleId of this.roleIds) {
|
||||
let role = this.userManager.roles.get(roleId);
|
||||
let role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
this.connector.on(event, () => {
|
||||
for (let plugin of this._controller.plugins.values()) {
|
||||
plugin.onHostConnectionEvent(this, event);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class InstanceInfo {
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: string,
|
||||
public factorioVersion?: lib.TargetVersion,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
@@ -23,7 +23,7 @@ export default class InstanceInfo {
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
@@ -45,6 +45,7 @@ export default class InstanceInfo {
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import ControllerUser from "./ControllerUser";
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
roles: Map<lib.Role["id"], lib.Role> = new Map();
|
||||
users: Map<ControllerUser["id"], ControllerUser> = new Map();
|
||||
dirty = false;
|
||||
|
||||
@@ -20,7 +19,8 @@ export default class UserManager {
|
||||
onlineUsers = new Set<ControllerUser>();
|
||||
|
||||
constructor(
|
||||
private _config: lib.ControllerConfig
|
||||
private _config: lib.ControllerConfig,
|
||||
private _roles: lib.SubscribableDatastore<lib.Role>,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -31,14 +31,15 @@ export default class UserManager {
|
||||
async load(filePath: string): Promise<void> {
|
||||
try {
|
||||
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
|
||||
for (let serializedRole of content.roles) {
|
||||
let role = lib.Role.fromJSON(serializedRole);
|
||||
this.roles.set(role.id, role);
|
||||
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
|
||||
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
|
||||
await this._roles.save(); // Force a save to prevent data loss
|
||||
content = content.users;
|
||||
}
|
||||
|
||||
let duplicates = 0;
|
||||
for (let serializedUser of content.users) {
|
||||
let user = ControllerUser.fromJSON(serializedUser, this);
|
||||
for (const serializedUser of content) {
|
||||
const user = ControllerUser.fromJSON(serializedUser, this._roles);
|
||||
const existingUser = this.users.get(user.id);
|
||||
if (existingUser) {
|
||||
// Required migration to all lowercase ids in alpha 19
|
||||
@@ -65,30 +66,14 @@ export default class UserManager {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Create default roles if loading failed
|
||||
lib.ensureDefaultAdminRole(this.roles);
|
||||
lib.ensureDefaultPlayerRole(this.roles);
|
||||
}
|
||||
}
|
||||
|
||||
async save(filePath: string): Promise<void> {
|
||||
let serializedRoles = [];
|
||||
for (let role of this.roles.values()) {
|
||||
serializedRoles.push(role.toJSON());
|
||||
}
|
||||
|
||||
let serializedUsers = [];
|
||||
for (let user of this.users.values()) {
|
||||
serializedUsers.push(user.toJSON(true));
|
||||
}
|
||||
|
||||
let serialized = {
|
||||
users: serializedUsers,
|
||||
roles: serializedRoles,
|
||||
};
|
||||
this.dirty = false;
|
||||
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
|
||||
await lib.safeOutputFile(filePath,
|
||||
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,11 +88,11 @@ export default class UserManager {
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._config.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
|
||||
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
let user = new ControllerUser(this, 0, name, roles);
|
||||
let user = new ControllerUser(this._roles, 0, name, roles);
|
||||
this.users.set(user.id, user);
|
||||
this.dirty = true;
|
||||
return user;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type WebSocket from "ws";
|
||||
|
||||
import events from "events";
|
||||
import assert from "assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
@@ -113,8 +114,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
|
||||
_timedOut() {
|
||||
logger.verbose("Connector | Connection timed out");
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
}
|
||||
|
||||
_attachSocketHandlers() {
|
||||
@@ -130,8 +130,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
this._socket = null;
|
||||
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
|
||||
} else {
|
||||
this._state = "resuming";
|
||||
@@ -187,8 +186,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
if (this._state === "resuming") {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,8 +221,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
||||
*/
|
||||
setClosing() {
|
||||
if (this._state === "resuming") {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
|
||||
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
|
||||
let name = pluginInfo.name;
|
||||
let loaded = req.app.locals.controller.plugins.has(name);
|
||||
let enabled = loaded && req.app.locals.controller.config.get(
|
||||
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
|
||||
) as unknown as boolean;
|
||||
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
|
||||
// Note: Cast through unknown is needed because load_plugin is
|
||||
// defined at runtime and no other fields in the controller config
|
||||
// currently have the boolean type.
|
||||
@@ -266,6 +264,12 @@ async function uploadExport(req: Request, res: Response) {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
type ProxyStreamEvents = {
|
||||
"error": [ err: any ],
|
||||
"close": [],
|
||||
"timeout": [],
|
||||
"source": [],
|
||||
}
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
@@ -274,7 +278,7 @@ export interface ProxyStream {
|
||||
size?: string;
|
||||
mime?: string;
|
||||
filename: string | null;
|
||||
events: events.EventEmitter;
|
||||
events: events.EventEmitter<ProxyStreamEvents>;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
|
||||
@@ -114,7 +114,7 @@ async function execFile(cmd, args) {
|
||||
escaped,
|
||||
{
|
||||
shell: true,
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
@@ -396,7 +396,7 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
@@ -689,7 +689,8 @@ async function inquirerMissingArgs(args) {
|
||||
}
|
||||
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
answers = inquirer.prompt([
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "remoteNpm",
|
||||
|
||||
@@ -50,7 +50,7 @@ function escapeArg(arg) {
|
||||
// For all other platforms the command is /bin/sh -c <command arg arg ...>
|
||||
// The command and its arguments are passed as a single argument to the shell.
|
||||
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
@@ -3,6 +3,8 @@ const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const _package = require("./package.json");
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
|
||||
@@ -92,8 +94,8 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
const commonPath = path.resolve(__dirname, "./templates/common");
|
||||
|
||||
// Files included in all templates
|
||||
files.set(".gitignore", path.join(commonPath, ".gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, ".npmignore"));
|
||||
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
|
||||
files.set("package.json", path.join(commonPath, "package.json"));
|
||||
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
@@ -102,6 +104,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
prepare.push("tsc --build");
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
@@ -109,7 +112,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --env production");
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
@@ -139,7 +142,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Files for lua modules
|
||||
if (templates.includes("module")) {
|
||||
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
|
||||
files.set("module/module.json", path.join(commonPath, "module/module.json"));
|
||||
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
@@ -147,7 +149,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +165,25 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
config: config,
|
||||
// Weather this is ts or js
|
||||
typescript: !javascriptOnly,
|
||||
ext: ext,
|
||||
// Which templates where requested
|
||||
multi_context: pluginContexts > 1,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
instance: templates.includes("instance"),
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
multi_context: pluginContexts > 1,
|
||||
plugin_name: pluginName,
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
node_version: _package.engines.node,
|
||||
prepare: prepare.join(" && "),
|
||||
ext: ext,
|
||||
plugin_name: pluginName,
|
||||
};
|
||||
|
||||
// Write all the template files
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,14 @@
|
||||
--[[
|
||||
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
|
||||
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
|
||||
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
|
||||
Most of your code will be within this one file, but you can create more and require them
|
||||
|
||||
clusterio_api exposes functions to enable communication with your InstancePlugin
|
||||
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
|
||||
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
@@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat")
|
||||
local MyModule = {
|
||||
}
|
||||
|
||||
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
|
||||
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
|
||||
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
-- If you use any of these events and access globalData then make sure to call this function first
|
||||
--[[
|
||||
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
|
||||
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
|
||||
There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
If you use any of these events and access `script_data` then make sure to call this function first
|
||||
]]
|
||||
local script_data = {}
|
||||
local function setup_script_data()
|
||||
if compat.script_data["__plugin_name__"] == nil then
|
||||
@@ -28,12 +41,13 @@ end
|
||||
|
||||
--- Public methods should be available though your top level module table
|
||||
function MyModule.foo()
|
||||
game.print("foo")
|
||||
game.print("foo: " .. (script_data.counter or 0))
|
||||
end
|
||||
|
||||
--- Private methods should be local to the file, this will prevent others from calling it
|
||||
local function bar()
|
||||
game.print("bar")
|
||||
script_data.counter = (script_data.counter or 0) + 1
|
||||
game.print("bar: " .. script_data.counter)
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
|
||||
@@ -61,7 +75,7 @@ local function on_nth_tick_300()
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per module
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per file
|
||||
local e = defines.events
|
||||
local events = {
|
||||
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||
@@ -73,7 +87,7 @@ local on_nth_tick = {
|
||||
[300] = on_nth_tick_300,
|
||||
}
|
||||
|
||||
--- Always return the top level module table from control, this is how clusterio will access your event handlers
|
||||
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
|
||||
MyModule.events = events --- @package
|
||||
MyModule.on_nth_tick = on_nth_tick --- @package
|
||||
return MyModule
|
||||
@@ -1,3 +1,9 @@
|
||||
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
|
||||
-- luacheck: globals __plugin_name__
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
Typically this would be your control file as shown in the example below
|
||||
]]
|
||||
|
||||
-- Access using `/sc __plugin_name__.foo()`
|
||||
__plugin_name__ = require("modules/__plugin_name__/control")
|
||||
@@ -1,4 +1,8 @@
|
||||
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
|
||||
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
|
||||
-- Access the exports from other modules using require("modules/__plugin_name__")
|
||||
--[[
|
||||
It is typical for a module to have a control file as the entry point, although this is not a requirement
|
||||
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
|
||||
You are not required to specify any default export, but note that a module can still require yours directly
|
||||
]]
|
||||
|
||||
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
|
||||
return require("modules/__plugin_name__/control")
|
||||
@@ -12,10 +12,10 @@
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
@@ -32,13 +32,13 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if web | controller // The controller uses webpack even when there is no plugin code
|
||||
//%if webpack
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
dist/
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
dist/web
|
||||
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
@@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), {
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
//%if web
|
||||
"./web": "./web/index.__ext__x",
|
||||
//%endif
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} = require("./messages");
|
||||
//%endif
|
||||
|
||||
@@ -26,7 +26,8 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
@@ -70,6 +71,7 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
|
||||
@@ -7,12 +7,14 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -21,6 +23,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
@@ -29,6 +32,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
|
||||
@@ -40,7 +44,7 @@ const plugin = {
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
|
||||
@@ -24,18 +24,26 @@ class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Called once rcon becomes available
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
this.logger.info("instance::onExit");
|
||||
}
|
||||
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages";
|
||||
//%endif
|
||||
|
||||
@@ -25,7 +25,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
this.logger.info(JSON.stringify(request));
|
||||
const values = [...this.exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
|
||||
@@ -6,12 +6,14 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -20,6 +22,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if web
|
||||
@@ -28,6 +31,7 @@ lib.definePermission({
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
grantByDefault: true,
|
||||
});
|
||||
//%endif
|
||||
//%if config
|
||||
|
||||
@@ -31,18 +31,25 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Called once rcon becomes available
|
||||
this.logger.info("instance::onStart");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
this.logger.info("instance::onStop");
|
||||
}
|
||||
|
||||
onExit() {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
this.logger.info("instance::onExit");
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
await this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.browser.json" },
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "es2023"],
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"noEmitOnError": true,
|
||||
"outDir": "${configDir}/dist/browser",
|
||||
},
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||
],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"target": "es2022",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "${configDir}/dist/node"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
permission: "__plugin_name__.example.permission.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
|
||||
+78
-35
@@ -5,7 +5,7 @@
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import yargs from "yargs";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
@@ -143,8 +143,30 @@ interface CtlArguments {
|
||||
pluginList: string,
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
interface InitializeParameters {
|
||||
args: CtlArguments;
|
||||
shouldRun: boolean;
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>;
|
||||
rootCommands?: lib.CommandTree;
|
||||
controlConfig?: lib.ControlConfig;
|
||||
}
|
||||
|
||||
export async function initialize(
|
||||
argv: string | string[],
|
||||
ctlPlugins?: Map<string, BaseCtlPlugin>,
|
||||
noLoggerTransport?: boolean,
|
||||
): Promise<InitializeParameters> {
|
||||
// Build a fresh, isolated yargs parser each time this function is called.
|
||||
// If the currently loaded yargs object supports .reset() we use that (older versions),
|
||||
// otherwise we fall back to creating a brand-new parser instance by invoking the
|
||||
// yargs factory function directly with an empty argv array. This avoids Mocha’s
|
||||
// own CLI flags (or anything else that touched yargs earlier in the same process)
|
||||
// from leaking into subsequent parses inside the test runner.
|
||||
const parser: Argv = typeof (yargs as any).reset === "function"
|
||||
? (yargs as any).reset()
|
||||
: (yargs as any)([]);
|
||||
|
||||
parser
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
@@ -169,44 +191,47 @@ async function startControl() {
|
||||
})
|
||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||
.command("control-config", "Manage Control config", lib.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.wrap(parser.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
// eslint-disable-next-line node/no-sync
|
||||
let args = yargs.parseSync() as CtlArguments;
|
||||
let args = parser.parseSync(argv) as CtlArguments;
|
||||
|
||||
// Log stream for the ctl session.
|
||||
logger.add(
|
||||
new ConsoleTransport({
|
||||
errorLevels: Object.keys(levels),
|
||||
level: args.logLevel,
|
||||
format: new lib.TerminalFormat(),
|
||||
})
|
||||
);
|
||||
lib.handleUnhandledErrors();
|
||||
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return;
|
||||
// Log stream for the ctl session. Skipped in testing.
|
||||
if (!noLoggerTransport) {
|
||||
logger.add(
|
||||
new ConsoleTransport({
|
||||
errorLevels: Object.keys(levels),
|
||||
level: args.logLevel,
|
||||
format: new lib.TerminalFormat(),
|
||||
})
|
||||
);
|
||||
lib.handleUnhandledErrors();
|
||||
}
|
||||
|
||||
logger.verbose("Loading Plugins");
|
||||
let ctlPlugins = await loadPlugins(pluginList);
|
||||
// Discover and load plugins. This check exists to allow tests to inject plugins.
|
||||
if (!ctlPlugins || args._[0] === "plugin") {
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
const pluginList = await lib.loadPluginList(args.pluginList);
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await lib.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return { args, shouldRun: false };
|
||||
}
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
logger.verbose("Loading Plugins");
|
||||
ctlPlugins = await loadPlugins(pluginList);
|
||||
}
|
||||
|
||||
// Add all commands including from plugins and reparse with help and strict checking.
|
||||
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
|
||||
args = parser
|
||||
.help()
|
||||
.strict()
|
||||
.parse() as CtlArguments
|
||||
.parse(argv) as CtlArguments
|
||||
;
|
||||
|
||||
let controlConfig;
|
||||
@@ -226,25 +251,41 @@ async function startControl() {
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
parser.showHelp();
|
||||
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await lib.handleConfigCommand(args, controlConfig);
|
||||
return;
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
|
||||
}
|
||||
|
||||
// Determine which command is being executed.
|
||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
|
||||
}
|
||||
|
||||
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
|
||||
let commandPath = [...args._] as string[];
|
||||
let targetCommand: lib.CommandTree | lib.Command = rootCommands;
|
||||
while (commandPath.length && targetCommand instanceof lib.CommandTree) {
|
||||
targetCommand = targetCommand.get(commandPath.shift()!)!;
|
||||
}
|
||||
assert(targetCommand instanceof lib.Command);
|
||||
return targetCommand;
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
const {
|
||||
args,
|
||||
shouldRun,
|
||||
ctlPlugins,
|
||||
rootCommands,
|
||||
controlConfig,
|
||||
} = await initialize(process.argv.slice(2));
|
||||
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The remaining commands require connecting to the controller.
|
||||
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
@@ -263,6 +304,7 @@ async function startControl() {
|
||||
tlsCa,
|
||||
controlConfig.get("control.controller_token")!,
|
||||
);
|
||||
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
@@ -278,12 +320,13 @@ async function startControl() {
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const targetCommand = selectTargetCommand(args, rootCommands);
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
|
||||
+182
-80
@@ -197,28 +197,24 @@ controllerConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.send(new lib.ControllerConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
print(err);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
}
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.send(new lib.ControllerConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
print(err);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -487,33 +483,29 @@ hostConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -732,34 +724,30 @@ instanceConfigCommands.add(new lib.Command({
|
||||
editorSpawn.on("data", (data) => {
|
||||
process.stdout.pipe(data);
|
||||
});
|
||||
let doneEmitter = new events.EventEmitter();
|
||||
editorSpawn.on("exit", async (exit) => {
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
await events.once(editorSpawn, "exit");
|
||||
const data = await fs.readFile(tmpFile, "utf8");
|
||||
const final = await configToKeyVal(data);
|
||||
for (let index in final) {
|
||||
if (index in final) {
|
||||
try {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(
|
||||
instanceId,
|
||||
index,
|
||||
final[index],
|
||||
));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line
|
||||
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
|
||||
// If the string is empty, it's better to just print "" instead of nothing
|
||||
print("This message shouldn't normally appear; if the below message does not indicate it");
|
||||
print("was a user mistake, please report it to the clustorio devs.");
|
||||
// added this because it could be a missed entry in disallowedList
|
||||
// i've added all the vanilla clustorio configs, but there may be
|
||||
// some modded ones that need to be added.
|
||||
print(err);
|
||||
}
|
||||
}
|
||||
doneEmitter.emit("dot_on_done");
|
||||
});
|
||||
await events.once(doneEmitter, "dot_on_done");
|
||||
}
|
||||
try {
|
||||
await fs.unlink(tmpFile);
|
||||
} catch (err) {
|
||||
@@ -1050,17 +1038,87 @@ instanceCommands.add(new lib.Command({
|
||||
definition: ["start <instance>", "Start instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to start", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
|
||||
let instanceId = await lib.resolveInstance(control, args.instance);
|
||||
const instanceId = await lib.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["restart <instance>", "Restart instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
|
||||
yargs.options({
|
||||
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
|
||||
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
|
||||
const instanceId = await lib.resolveInstance(control, args.instance);
|
||||
await control.setLogSubscriptions({ instanceIds: [instanceId] });
|
||||
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
|
||||
control.keepOpen = args.keepOpen;
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
|
||||
yargs.options({
|
||||
"force": {
|
||||
describe: "Start instances even if they have exclude_from_start_all set",
|
||||
nargs: 0,
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { force: boolean }, control: Control) {
|
||||
// Get all instances
|
||||
const instances = await control.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be started
|
||||
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
|
||||
// Only start instances that are currently stopped
|
||||
if (instance.status !== "stopped") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Respect exclude_from_start_all setting unless --force is used
|
||||
if (!args.force && instance.excludeFromStartAll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (instancesToStart.length === 0) {
|
||||
print("No instances to start.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Starting ${instancesToStart.length} instance(s):`);
|
||||
for (const instance of instancesToStart) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Start all filtered instances
|
||||
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
|
||||
print(`✓ Started ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to start ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(startPromises);
|
||||
print("Start all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
|
||||
@@ -1107,6 +1165,41 @@ instanceCommands.add(new lib.Command({
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["stop-all", "Stop all running instances", () => {}],
|
||||
handler: async function(_args, control: Control) {
|
||||
// Get all instances
|
||||
const instances = await control.send(new lib.InstanceDetailsListRequest());
|
||||
|
||||
// Filter instances that should be stopped
|
||||
const instancesToStop = instances
|
||||
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
|
||||
|
||||
if (instancesToStop.length === 0) {
|
||||
print("No instances to stop.");
|
||||
return;
|
||||
}
|
||||
|
||||
print(`Stopping ${instancesToStop.length} instance(s):`);
|
||||
for (const instance of instancesToStop) {
|
||||
print(` - ${instance.name} (ID: ${instance.id})`);
|
||||
}
|
||||
|
||||
// Stop all filtered instances
|
||||
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
|
||||
try {
|
||||
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
|
||||
print(`✓ Stopped ${instance.name}`);
|
||||
} catch (error) {
|
||||
print(`✗ Failed to stop ${instance.name}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
print("Stop all operation completed.");
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new lib.Command({
|
||||
definition: ["kill <instance>", "Kill instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
|
||||
@@ -1269,7 +1362,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
if (!version) {
|
||||
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
if (!lib.isFullVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
@@ -1322,8 +1415,8 @@ modPackCommands.add(new lib.Command({
|
||||
modPack.name = args.name;
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
@@ -1403,8 +1496,8 @@ modPackCommands.add(new lib.Command({
|
||||
if (args.name) { modPack.name = args.name; }
|
||||
if (args.description) { modPack.description = args.description; }
|
||||
if (args.factorioVersion) {
|
||||
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
|
||||
if (!lib.isPartialVersion(args.factorioVersion)) {
|
||||
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
|
||||
}
|
||||
modPack.factorioVersion = args.factorioVersion;
|
||||
}
|
||||
@@ -1458,6 +1551,9 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
|
||||
for (let [field, value] of Object.entries(modInfo)) {
|
||||
if (value instanceof Array) {
|
||||
@@ -1545,7 +1641,7 @@ modCommands.add(new lib.Command({
|
||||
) {
|
||||
let response = await control.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
args.factorioVersion,
|
||||
lib.normaliseApiVersion(args.factorioVersion as any),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
@@ -1624,6 +1720,9 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
|
||||
|
||||
let url = new URL(control.config.get("control.controller_url")!);
|
||||
@@ -1664,6 +1763,9 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
},
|
||||
}));
|
||||
@@ -2059,7 +2161,7 @@ userCommands.add(new lib.Command({
|
||||
exportType = "whitelist";
|
||||
}
|
||||
|
||||
fs.writeJSON(args.filepath, await control.send(
|
||||
await fs.writeJSON(args.filepath, await control.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), { spaces: 2 });
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
|
||||
@@ -243,7 +243,7 @@ async function startHost() {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ async function startHost() {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+45
-17
@@ -250,7 +250,7 @@ export class HostRouter {
|
||||
* Connects to the controller over the WebSocket and manages intsances.
|
||||
*/
|
||||
export default class Host extends lib.Link {
|
||||
declare ["connector"]: HostConnector;
|
||||
declare "connector": HostConnector;
|
||||
|
||||
router = new HostRouter(this);
|
||||
/**
|
||||
@@ -410,7 +410,7 @@ export default class Host extends lib.Link {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -693,7 +693,8 @@ export default class Host extends lib.Link {
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
: instanceInfo.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -805,19 +806,46 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
async handleSystemInfoRequest() {
|
||||
if (!this.config.restartRequired) {
|
||||
// If a restart isn't already required, then test if a new version is installed
|
||||
try {
|
||||
const runningVersion = this.config.get("host.version");
|
||||
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
}
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
|
||||
|
||||
let packageName = "@clusterio/host";
|
||||
try {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("host.version");
|
||||
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second check plugin versions
|
||||
for (const pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJson = await fs.readJSON(path.join(pluginInfo.requirePath, "package.json"));
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async handleHostMetricsRequest() {
|
||||
@@ -1087,7 +1115,7 @@ export default class Host extends lib.Link {
|
||||
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this.plugins.has(pluginInfo.name),
|
||||
this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean,
|
||||
this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1201,7 +1229,7 @@ export default class Host extends lib.Link {
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,8 @@ export default class Instance extends lib.Link {
|
||||
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
version: this.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
version: this.config.get("factorio.version") as lib.TargetVersion,
|
||||
executablePath: this.config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
|
||||
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
|
||||
@@ -289,6 +290,7 @@ export default class Instance extends lib.Link {
|
||||
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
|
||||
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
|
||||
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
|
||||
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
|
||||
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
|
||||
this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this));
|
||||
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
|
||||
@@ -552,7 +554,8 @@ end`.replace(/\r?\n/g, " ");
|
||||
this.id,
|
||||
status,
|
||||
this.server.gamePort,
|
||||
status === "running"? this.server.version : this.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -650,7 +653,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
if (
|
||||
!pluginInfo.instanceEntrypoint
|
||||
|| !this._host.serverPlugins.has(pluginInfo.name)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -1300,6 +1303,14 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this.stop();
|
||||
// Stopping invalidates this instance object, a new one must be created by the host
|
||||
// To do this we simulate a waking an instance and sending a start request
|
||||
const instanceConnection = await this._host._connectInstance(this.id);
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ async function loadLayeredIcon(
|
||||
iconLayer.scale(realScale);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
|
||||
x += xs;
|
||||
y += ys;
|
||||
|
||||
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
||||
* @returns Array with path to data dir and version found.
|
||||
* @internal
|
||||
*/
|
||||
async function findVersion(factorioDir: string, targetVersion: string) {
|
||||
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) {
|
||||
|
||||
// There are two supported setups: having the factorio dir be the actual
|
||||
// install directory, and having the factorio dir be a folder containing
|
||||
@@ -423,7 +423,7 @@ export interface FactorioServerOptions {
|
||||
* Version of Factorio to use. Can also be the string "latest" to use
|
||||
* the latest version found in `factorioDir`.
|
||||
*/
|
||||
version?: string,
|
||||
version?: lib.TargetVersion,
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port to host game on. */
|
||||
@@ -454,6 +454,32 @@ export interface FactorioServerOptions {
|
||||
shutdownTimeoutMs?: number,
|
||||
}
|
||||
|
||||
type FactorioServerEvents = {
|
||||
// Public Events
|
||||
"stdout": [ rawLine: Buffer ],
|
||||
"stderr": [ rawLine: Buffer ],
|
||||
"output": [ parsed: lib.ParsedFactorioOutput, line: string ]
|
||||
|
||||
"error": [ err: any ],
|
||||
"rcon-ready": [],
|
||||
"game-ready": [],
|
||||
"exit": [],
|
||||
|
||||
"autosave-start": [ name: string ],
|
||||
"autosave-finished": [ name: string ],
|
||||
"save-finished": [],
|
||||
|
||||
"whitelist-change": [ added: string[], removed: string[] ],
|
||||
|
||||
// Private Events
|
||||
"_autosave": [ name: string ]
|
||||
"_saving": [],
|
||||
"_saved": [],
|
||||
|
||||
// IPS events
|
||||
[ipcEvent: `ipc-${string}`]: [ event: any ],
|
||||
};
|
||||
|
||||
/**
|
||||
* Factorio Server interface
|
||||
*
|
||||
@@ -473,7 +499,7 @@ export interface FactorioServerOptions {
|
||||
* - exit - invoked when the sterver has exited
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class FactorioServer extends events.EventEmitter {
|
||||
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
@@ -497,7 +523,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
_writeDir: string;
|
||||
|
||||
// Resolved in init
|
||||
_version: string | null = null;
|
||||
_version: lib.FullVersion | null = null;
|
||||
_dataDir: string | null = null;
|
||||
|
||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||
@@ -506,7 +532,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
_whitelist = new Set<string>();
|
||||
|
||||
_logger: lib.Logger;
|
||||
_targetVersion: string;
|
||||
_targetVersion: lib.TargetVersion;
|
||||
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
|
||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||
_rconClient: Rcon | null= null;
|
||||
@@ -721,7 +747,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
this._rconClient.on("authenticated", () => {
|
||||
this._rconReady = true;
|
||||
this.emit("rcon-ready");
|
||||
});
|
||||
});
|
||||
this._rconClient.on("end", () => {
|
||||
this._rconClient = null;
|
||||
if (!this._rconReady) {
|
||||
@@ -840,7 +866,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
* "latest" (the default) was specified as `factorioVersion` to the constructor.
|
||||
* This will be undefined before the server is initialized, or if init failed.
|
||||
*/
|
||||
get version(): string | undefined {
|
||||
get version(): lib.FullVersion | undefined {
|
||||
return this._version ?? undefined;
|
||||
}
|
||||
|
||||
@@ -1212,7 +1238,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
// complete before the RCON connection can be used
|
||||
if (!this._rconReady) {
|
||||
// Not using events.once here to avoid throwing on error events.
|
||||
await new Promise(resolve => this.once("rcon-ready", resolve));
|
||||
await new Promise<void>(resolve => this.once("rcon-ready", resolve));
|
||||
}
|
||||
|
||||
// The Factorio server may have decided to get ahead of us and
|
||||
|
||||
@@ -15,4 +15,3 @@ export * from "./src/subscriptions";
|
||||
|
||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
|
||||
Vendored
+7
-7
@@ -1,10 +1,10 @@
|
||||
export interface BuildArgs {
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
factorioVersion?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clean?: boolean;
|
||||
pack?: boolean;
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
};
|
||||
export function build(args: BuildArgs): Promise<void>;
|
||||
@@ -115,9 +115,21 @@ async function main() {
|
||||
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
|
||||
"build": { describe: "Build mod(s)", type: "boolean", default: true },
|
||||
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
|
||||
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
|
||||
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
|
||||
"factorio-version": { describe: "Override factorio_version", type: "string" },
|
||||
"source-dir": {
|
||||
describe: "Path to mod source files",
|
||||
type: "string",
|
||||
nargs: 1,
|
||||
normalize: true,
|
||||
demandOption: true,
|
||||
},
|
||||
"output-dir": {
|
||||
describe: "Path to output built mod(s)",
|
||||
type: "string",
|
||||
nargs: 1,
|
||||
normalize: true,
|
||||
default: "dist/factorio",
|
||||
},
|
||||
})
|
||||
.strict()
|
||||
.argv
|
||||
|
||||
@@ -35,4 +35,3 @@ export * from "./src/rce_ops";
|
||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as ModStore } from "./src/ModStore";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
+106
-68
@@ -1,16 +1,16 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import events from "events";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging";
|
||||
import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data";
|
||||
import TypedEventEmitter from "./TypedEventEmitter";
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
import { Writable } from "stream";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
|
||||
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
change: (mod: ModInfo) => void;
|
||||
"change": [ mod: ModInfo ];
|
||||
}
|
||||
|
||||
interface ModsInfoResponse {
|
||||
@@ -37,7 +37,7 @@ interface ModPortalResponse {
|
||||
results: Static<typeof ModPortalDetailsSchema>[];
|
||||
}
|
||||
|
||||
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
|
||||
export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
constructor(
|
||||
public modsDirectory: string,
|
||||
public files: Map<string, ModInfo>,
|
||||
@@ -45,7 +45,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
super();
|
||||
}
|
||||
|
||||
getMod(name: string, version: string, sha1?: string) {
|
||||
getMod(name: string, version: FullVersion, sha1?: string) {
|
||||
const mod = this.files.get(ModInfo.filename(name, version));
|
||||
if (!mod || sha1 && sha1 !== mod.sha1) {
|
||||
return undefined;
|
||||
@@ -87,11 +87,11 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
async loadMod(name: string, version: string) {
|
||||
async loadMod(name: string, version: FullVersion) {
|
||||
return await this.loadFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
async deleteMod(name: string, version: string) {
|
||||
async deleteMod(name: string, version: FullVersion) {
|
||||
return await this.deleteFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
@@ -155,10 +155,46 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
return new this(modsDirectory, files);
|
||||
}
|
||||
|
||||
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
|
||||
/**
|
||||
* Stream a url's content into a file.
|
||||
*
|
||||
* @param url - Web path to stream from.
|
||||
* @param filePath - File path to stream to.
|
||||
*/
|
||||
private static async downloadFile(url: string | URL, filePath: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing.");
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(filePath);
|
||||
const writer = Writable.toWeb(fileStream);
|
||||
await response.body.pipeTo(writer);
|
||||
} catch (error) {
|
||||
logger.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a matching version from a selection of available versions.
|
||||
*
|
||||
* @param releases - Array of mod releases taken from the mod portal.
|
||||
* @param version - The required version to install.
|
||||
* @param username - Factorio username to download using.
|
||||
* @param token - Factorio token to download using.
|
||||
*/
|
||||
private async downloadModFromReleases(
|
||||
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
|
||||
username: string, token: string
|
||||
) {
|
||||
const selectedRelease = releases.find(release => release.version === version);
|
||||
const selectedRelease = releases.find(release => version.testVersion(release.version));
|
||||
|
||||
if (selectedRelease === undefined) {
|
||||
logger.warn(`Mod release matching version ${version} not found.`);
|
||||
@@ -190,96 +226,83 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads all provided mods, does not perform chunking or filtering of builtins
|
||||
*
|
||||
* @param mods - An array of mods to download
|
||||
* @param username - Factorio username used for auth to download mods
|
||||
* @param token - Factorio token used for auth to download mods
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
private async downloadModsChunk(
|
||||
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
|
||||
mods: ModNameVersionPair[],
|
||||
username: string, token: string,
|
||||
factorioVersion: ApiVersion,
|
||||
) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", "max");
|
||||
url.searchParams.set("version", factorioVersion);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({ namelist: modNames.join(",") }),
|
||||
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const mods = (await response.json() as ModsInfoResponse).results;
|
||||
|
||||
await Promise.all(mods.map((mod) => {
|
||||
const modReleases = (await response.json() as ModsInfoResponse).results;
|
||||
await Promise.all(modReleases.map((mod) => {
|
||||
const modVersion = mods.find(m => m.name === mod.name)!.version;
|
||||
if (mod.releases) {
|
||||
return this.downloadModFromReleases(mod.releases,
|
||||
modVersions[modNames.indexOf(mod.name)], username, token);
|
||||
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
|
||||
} else if (mod.latest_release) {
|
||||
return this.downloadModFromReleases([mod.latest_release],
|
||||
modVersions[modNames.indexOf(mod.name)], username, token);
|
||||
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
|
||||
}
|
||||
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Downloads all provided mods to the mod dir.
|
||||
*
|
||||
* downloads mods to the mod dir, will not download mods for which are already in the mod directory
|
||||
*
|
||||
* @param modMap a map of what mods to download and what version to download
|
||||
* @param username factorio username used for auth to download mods
|
||||
* @param token factorio token used for auth to download mods
|
||||
* @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
* Handles chunking to prevent errors due to URL length.
|
||||
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
|
||||
*
|
||||
* @param mods - An array of mods to download.
|
||||
* @param username - Factorio username used for auth to download mods.
|
||||
* @param token - Factorio token used for auth to download mods.
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
|
||||
async downloadMods(
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
|
||||
) {
|
||||
const chunkSize = 500;
|
||||
let futures: Promise<void>[] = [];
|
||||
modMap = new Map(modMap);
|
||||
|
||||
// Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks
|
||||
const versionParts = factorioVersion.split(".");
|
||||
if (versionParts.length > 2) {
|
||||
factorioVersion = versionParts.slice(0, 2).join(".");
|
||||
}
|
||||
|
||||
const futures: Promise<void>[] = [];
|
||||
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
|
||||
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
|
||||
|
||||
// get rid of mods already downloaded, built-in, or core
|
||||
modMap.forEach((modVersion, modName, map) => {
|
||||
const filename = `${modName}_${modVersion}.zip`;
|
||||
// Filter existing, built-in from getBuiltinModNames, and explicitly filter "core"
|
||||
if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") {
|
||||
map.delete(modName);
|
||||
}
|
||||
});
|
||||
|
||||
const modNames = Array.from(modMap.keys());
|
||||
const modVersions = Array.from(modMap.values());
|
||||
for (let i = 0; i < modMap.size; i += chunkSize) {
|
||||
for (let i = 0; i < mods.length; i += chunkSize) {
|
||||
futures.push(
|
||||
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize),
|
||||
username, token, factorioVersion)
|
||||
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(futures);
|
||||
}
|
||||
|
||||
static async downloadFile(url: string | URL, filePath: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing.");
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(filePath);
|
||||
const writer = Writable.toWeb(fileStream);
|
||||
await response.body.pipeTo(writer);
|
||||
} catch (error) {
|
||||
logger.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
/**
|
||||
* Filter an array of mods based on if they are installed.
|
||||
*
|
||||
* @param mods - Array of mods to filer.
|
||||
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
|
||||
* @returns Filtered array of mods based on install state.
|
||||
*/
|
||||
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
|
||||
const installedMods = Array.from(this.mods());
|
||||
return mods.filter(mod => (
|
||||
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,4 +355,19 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
|
||||
return allMods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full release list for a mod including dependencies.
|
||||
*
|
||||
* @param modName - Name of the mod to fetch the releases of
|
||||
* @returns Mod portal response
|
||||
*/
|
||||
static async fetchModReleases(modName: string) {
|
||||
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json() as ModsInfoResponse["results"][number];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import EventEmitter from "events";
|
||||
|
||||
/**
|
||||
* Type checked version of EventEmitter
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface Events {
|
||||
* "foo": (a: number, b: number) => void,
|
||||
* "bar": (spam: string) => void,
|
||||
* }
|
||||
*
|
||||
* class Emitter extends TypedEventEmitter<keyof Events, Events> {}
|
||||
*
|
||||
* const e = new Emitter();
|
||||
* e.emit("foo", 1, 2) // type checked arguments
|
||||
* ```
|
||||
*/
|
||||
export default abstract class TypedEventEmitter<
|
||||
Keys extends string,
|
||||
Events extends Record<Keys, (...args: [...any]) => void>,
|
||||
> extends EventEmitter {
|
||||
declare ["on"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["off"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["once"]: <E extends Keys>(event: E, listener: Events[E]) => this;
|
||||
declare ["emit"]: <E extends Keys>(event: E, ...args: Parameters<Events[E]>) => boolean;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Configuration classes
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import * as fs from "fs-extra";
|
||||
import events from "events";
|
||||
|
||||
import TypedEventEmitter from "../TypedEventEmitter";
|
||||
import isDeepStrictEqual from "../is_deep_strict_equal";
|
||||
import { basicType } from "../helpers";
|
||||
import * as libSchema from "../schema";
|
||||
@@ -152,7 +152,7 @@ function defaultValue(def: FieldDefinition) {
|
||||
}
|
||||
|
||||
export type ConfigEvents = {
|
||||
fieldChanged: (name: string, curr: FieldValue, prev: FieldValue) => void;
|
||||
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
||||
}
|
||||
|
||||
export enum ConfigAccess {
|
||||
@@ -178,10 +178,7 @@ export enum ConfigAccess {
|
||||
*/
|
||||
export class Config<
|
||||
Fields extends { [Field in keyof Fields]: FieldValue },
|
||||
> extends TypedEventEmitter<
|
||||
keyof ConfigEvents,
|
||||
ConfigEvents
|
||||
> {
|
||||
> extends events.EventEmitter<ConfigEvents> {
|
||||
/**
|
||||
* Mapping of config name to field meta data
|
||||
* Note that mutating this object will lead to unexpected behaviour.
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ControllerConfigFields {
|
||||
"controller.allow_remote_updates": boolean;
|
||||
"controller.allow_plugin_updates": boolean;
|
||||
"controller.allow_plugin_install": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,6 +268,7 @@ export interface HostConfigFields {
|
||||
"host.allow_remote_updates": boolean;
|
||||
"host.allow_plugin_updates": boolean;
|
||||
"host.allow_plugin_install": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,6 +400,8 @@ export interface InstanceConfigFields {
|
||||
"instance.id": number;
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
|
||||
"factorio.version": string;
|
||||
"factorio.executable_path": string | null;
|
||||
@@ -465,6 +469,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.exclude_from_start_all": {
|
||||
description: "Exclude this instance from the 'Start all' button operation",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
restartRequired: true,
|
||||
|
||||
@@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import * as libHash from "../hash";
|
||||
import * as libSchema from "../schema";
|
||||
import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
|
||||
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
|
||||
};
|
||||
|
||||
const lettersRegex = /^[a-zA-Z]+$/;
|
||||
|
||||
export class ModDependency {
|
||||
public type: ModDependencyType;
|
||||
public name: string;
|
||||
public version: ModVersionEquality | undefined;
|
||||
|
||||
static getTypeFromPrefix(prefix: string): ModDependencyType {
|
||||
switch (prefix) {
|
||||
case "!":
|
||||
return "incompatible";
|
||||
case "?":
|
||||
return "optional";
|
||||
case "(?)":
|
||||
return "hidden";
|
||||
case "~":
|
||||
return "unordered";
|
||||
case "":
|
||||
return "required";
|
||||
default:
|
||||
throw new Error(`Invalid dependency prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
public specification: string,
|
||||
) {
|
||||
const parts = specification.split(" ");
|
||||
|
||||
// Fix mods which include spaces in their name, joins consecutive words together
|
||||
let index = 0;
|
||||
while (index < parts.length - 1) {
|
||||
const current = parts[index];
|
||||
const next = parts[index + 1];
|
||||
if (lettersRegex.test(current) && lettersRegex.test(next)) {
|
||||
parts[index] = `${current} ${next}`;
|
||||
parts.splice(index + 1, 1);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the dependency specification
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
break;
|
||||
case 2:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
break;
|
||||
case 3:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
|
||||
break;
|
||||
case 4:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid dependency specification: ${specification}`);
|
||||
}
|
||||
}
|
||||
|
||||
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
|
||||
const mod = mods.find(m => m.name === this.name);
|
||||
if (mod === undefined) {
|
||||
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
|
||||
} else if (this.type === "incompatible") {
|
||||
return "incompatible";
|
||||
} else if (this.version && !this.version.testVersion(mod.version)) {
|
||||
return "wrong_version";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isSatisfied(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.checkUnsatisfiedReason(mods) === undefined;
|
||||
}
|
||||
|
||||
get incompatible() {
|
||||
return this.type === "incompatible";
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.type === "unordered" || this.type === "required";
|
||||
}
|
||||
|
||||
get optional() {
|
||||
return this.type === "hidden" || this.type === "optional";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.specification;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info about a mod available on the controller.
|
||||
@@ -35,13 +154,13 @@ export default class ModInfo {
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
version = "";
|
||||
version = "0.0.0" as FullVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerModVersion(this.version);
|
||||
return integerFullVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,21 +197,38 @@ export default class ModInfo {
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
factorioVersion = "0.12";
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependiences for this mod.
|
||||
* Dependencies for this mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
dependencies = ["base"];
|
||||
dependencies = [new ModDependency("base")];
|
||||
|
||||
/**
|
||||
* Dependencies for this mod.
|
||||
* As they would be represented in info.json.
|
||||
*/
|
||||
get dependencySpecifications() {
|
||||
return this.dependencies.map(d => d.specification);
|
||||
}
|
||||
|
||||
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.dependencies
|
||||
.map(d => d.checkUnsatisfiedReason(mods))
|
||||
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
|
||||
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
|
||||
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
|
||||
), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected name of zip file containing this mod.
|
||||
@@ -107,7 +243,7 @@ export default class ModInfo {
|
||||
* @param version - Mod's version
|
||||
* @returns string containing {name}_{version}.zip
|
||||
*/
|
||||
static filename(name: string, version: string) {
|
||||
static filename(name: string, version: FullVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -137,13 +273,13 @@ export default class ModInfo {
|
||||
// Content of info.json found in mod files
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -172,7 +308,7 @@ export default class ModInfo {
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
|
||||
|
||||
// Additional data
|
||||
if (json.size) { modInfo.size = json.size; }
|
||||
@@ -195,8 +331,8 @@ export default class ModInfo {
|
||||
if (this.homepage) { json.homepage = this.homepage; }
|
||||
if (this.description) { json.description = this.description; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
|
||||
json.dependencies = this.dependencies;
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
if (this.size) { json.size = this.size; }
|
||||
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
|
||||
@@ -227,10 +363,6 @@ export default class ModInfo {
|
||||
throw new Error("Mod's info.json is not valid");
|
||||
}
|
||||
|
||||
if (!modVersionRegExp.test(modInfo.version)) {
|
||||
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(modPath);
|
||||
return this.fromJSON({
|
||||
...modInfo,
|
||||
|
||||
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import { integerFactorioVersion } from "./version";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
export const ModSettingColor = Type.Object({
|
||||
r: Type.Number(),
|
||||
@@ -40,17 +46,21 @@ export interface ModRecord {
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: string,
|
||||
version: FullVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
error?: "missing"|"bad_checksum",
|
||||
error?: "missing" | "bad_checksum",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
|
||||
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
|
||||
info?: ModInfo,
|
||||
}
|
||||
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -82,13 +92,13 @@ export default class ModPack {
|
||||
/**
|
||||
* Version of Factorio this mod pack is for
|
||||
*/
|
||||
factorioVersion = "1.1.0";
|
||||
factorioVersion = "1.1.0" as PartialVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +146,7 @@ export default class ModPack {
|
||||
"id": Type.Optional(Type.Integer()),
|
||||
"name": Type.String(),
|
||||
"description": Type.String(),
|
||||
"factorio_version": Type.String(),
|
||||
"factorio_version": PartialVersionSchema,
|
||||
"mods": Type.Array(ModRecordJsonSchema),
|
||||
"settings": Type.Object({
|
||||
"startup": ModSettingsJsonSchema,
|
||||
@@ -161,7 +171,7 @@ export default class ModPack {
|
||||
if (json.name) { modPack.name = json.name; }
|
||||
if (json.description) { modPack.description = json.description; }
|
||||
if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); }
|
||||
if (json.mods) { modPack.mods = new Map(json.mods.map((m: ModRecord) => [m.name, m])); }
|
||||
if (json.settings) {
|
||||
modPack.settings = {
|
||||
"startup": new Map(Object.entries(json.settings["startup"])),
|
||||
@@ -218,7 +228,7 @@ export default class ModPack {
|
||||
let buf = Buffer.from(modPackString, "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed mod pack string: zlib inflate failed");
|
||||
@@ -291,7 +301,7 @@ export default class ModPack {
|
||||
const sizeBytes = uint32Bytes(entries.length);
|
||||
const itemBytes = entries.flatMap(([key, item]) => [
|
||||
immutableStringBytes(key),
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
|
||||
propertyTreeBytes(item),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
@@ -385,39 +395,40 @@ export default class ModPack {
|
||||
|
||||
/** Array of default mod packs which should exist on a newly installed cluster */
|
||||
static defaultModPacks = [
|
||||
...["0.17", "0.18", "1.0", "1.1", "2.0"]
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Base Game ${version}`,
|
||||
description: `Factorio ${version} with no extra mods.`,
|
||||
factorio_version: version,
|
||||
} as any)),
|
||||
...["2.0"]
|
||||
...(["2.0"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Space Age ${version}`,
|
||||
description: `Factorio ${version} with Space Age expansion.`,
|
||||
factorio_version: version,
|
||||
mods: [
|
||||
{ name: "elevated-rails", enabled: true, version },
|
||||
{ name: "quality", enabled: true, version },
|
||||
{ name: "space-age", enabled: true, version },
|
||||
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns an array of ModInfo containing all mods included in factorio
|
||||
* Returns an array of ModRecord containing all mods included in factorio
|
||||
*
|
||||
* @param factorioVersion - Factorio version to get the default mods for
|
||||
* @return Built in mods for the given version
|
||||
*/
|
||||
static getBuiltinMods(factorioVersion: string) {
|
||||
static getBuiltinMods(factorioVersion: PartialVersion) {
|
||||
factorioVersion = normaliseFullVersion(factorioVersion);
|
||||
let defaultMods: ModRecord[] = [
|
||||
// "core" not included because core cannot be disabled
|
||||
{ name: "base", enabled: true, version: factorioVersion },
|
||||
];
|
||||
|
||||
const integerVersion = integerFactorioVersion(factorioVersion);
|
||||
if (integerVersion >= integerFactorioVersion("1.2")) {
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
if (integerVersion >= integerPartialVersion("1.2")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "elevated-rails", enabled: false, version: factorioVersion },
|
||||
{ name: "quality", enabled: false, version: factorioVersion },
|
||||
@@ -428,7 +439,7 @@ export default class ModPack {
|
||||
return defaultMods;
|
||||
}
|
||||
|
||||
static getBuiltinModNames(factorioVersion: string) {
|
||||
static getBuiltinModNames(factorioVersion: PartialVersion) {
|
||||
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export default class Role {
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: Set<string>,
|
||||
public updatedAtMs = 0,
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -17,22 +19,18 @@ export default class Role {
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static remapPermission(permission: string) {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
if (permission === "core.instance.save.list.subscribe") {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.id,
|
||||
json.name,
|
||||
json.description,
|
||||
new Set(json.permissions.map(this.remapPermission)),
|
||||
new Set(json.permissions),
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +40,8 @@ export default class Role {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
permissions: [...this.permissions],
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @author Hornwitser
|
||||
*/
|
||||
export { default as ExportManifest } from "./ExportManifest";
|
||||
export { default as ModInfo } from "./ModInfo";
|
||||
export { default as ModInfo, ModDependency } from "./ModInfo";
|
||||
export { ModSettingColor } from "./ModPack";
|
||||
export type { ModSetting, ModRecord } from "./ModPack";
|
||||
export { default as ModPack } from "./ModPack";
|
||||
@@ -21,5 +21,6 @@ export * from "./messages_host";
|
||||
export * from "./messages_instance";
|
||||
export * from "./messages_mod";
|
||||
export * from "./messages_user";
|
||||
export * from "./messages_role";
|
||||
export * from "./messages_plugin";
|
||||
export * from "./version";
|
||||
@@ -1,6 +1,6 @@
|
||||
// Note: Run compile_validator script after making changes to this file.
|
||||
import { TSchema, Type, Static } from "@sinclair/typebox";
|
||||
// eslint-disable-next-line node/no-missing-import
|
||||
|
||||
import messageValidate from "./message_validate"; // generated file
|
||||
import { StringEnum } from "./composites";
|
||||
|
||||
@@ -154,7 +154,7 @@ export class Message {
|
||||
static validate = messageValidate;
|
||||
|
||||
static fromJSON(json: any) {
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
if (json.type === "hello") { return MessageHello.fromJSON(json); }
|
||||
if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); }
|
||||
if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); }
|
||||
@@ -168,7 +168,7 @@ export class Message {
|
||||
if (json.type === "responseError") { return MessageResponseError.fromJSON(json); }
|
||||
if (json.type === "event") { return MessageEvent.fromJSON(json); }
|
||||
if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); }
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
||||
throw new Error(`Unrecognized message type ${json.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InstanceConfig } from "../config";
|
||||
import type { IControllerUser } from "./User";
|
||||
import type { MessageRequest } from "./messages_core";
|
||||
import { CollectorResultSerialized } from "../prometheus";
|
||||
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
|
||||
|
||||
|
||||
export const InstanceStatus = StringEnum([
|
||||
@@ -42,9 +43,10 @@ export class InstanceDetails {
|
||||
public assignedHost: number | undefined,
|
||||
public gamePort: number | undefined,
|
||||
public status: InstanceStatus,
|
||||
public factorioVersion: string | undefined,
|
||||
public factorioVersion: TargetVersion | undefined,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs = 0,
|
||||
public excludeFromStartAll = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -56,8 +58,9 @@ export class InstanceDetails {
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
]),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"excludeFromStartAll": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -69,6 +72,7 @@ export class InstanceDetails {
|
||||
json.status,
|
||||
json.factorioVersion,
|
||||
json.updatedAtMs,
|
||||
json.excludeFromStartAll,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +288,26 @@ export class InstanceStartRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceRestartRequest {
|
||||
declare ["constructor"]: typeof InstanceRestartRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.restart" as const;
|
||||
|
||||
constructor(
|
||||
public save?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"save": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.save);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveDetails {
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
@@ -721,8 +745,8 @@ export class HostInstanceUpdate {
|
||||
constructor(
|
||||
public config: Static<typeof InstanceConfig.jsonSchema>,
|
||||
public status: InstanceStatus,
|
||||
public gamePort: undefined | number,
|
||||
public factorioVersion: undefined | string,
|
||||
public gamePort: number | undefined,
|
||||
public factorioVersion: PartialVersion | undefined,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -731,7 +755,7 @@ export class HostInstanceUpdate {
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]),
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(PartialVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -829,7 +853,7 @@ export class InstanceStatusChangedEvent {
|
||||
public instanceId: number,
|
||||
public status: InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: string,
|
||||
public factorioVersion?: TargetVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -838,7 +862,7 @@ export class InstanceStatusChangedEvent {
|
||||
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
|
||||
]),
|
||||
"gamePort": Type.Optional(Type.Integer()),
|
||||
"factorioVersion": Type.Optional(Type.String()),
|
||||
"factorioVersion": Type.Optional(TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import ModInfo from "./ModInfo";
|
||||
import ModInfo, { ModDependency } from "./ModInfo";
|
||||
import ModPack from "./ModPack";
|
||||
import { JsonString, jsonArray } from "./composites";
|
||||
|
||||
import {
|
||||
FullVersion, FullVersionSchema,
|
||||
ApiVersion, ApiVersionSchema, normaliseApiVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
|
||||
export class ModPackGetRequest {
|
||||
declare ["constructor"]: typeof ModPackGetRequest;
|
||||
@@ -113,13 +119,13 @@ export class ModGetRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -148,7 +154,7 @@ export class ModSearchRequest {
|
||||
|
||||
constructor(
|
||||
public query: string,
|
||||
public factorioVersion: string,
|
||||
public factorioVersion: ApiVersion,
|
||||
public page: number,
|
||||
public pageSize?: number,
|
||||
public sort?: string,
|
||||
@@ -157,7 +163,7 @@ export class ModSearchRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"query": Type.String(),
|
||||
"factorioVersion": Type.String(),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"page": Type.Integer(),
|
||||
"pageSize": Type.Optional(Type.Integer()),
|
||||
"sort": Type.Optional(Type.String()),
|
||||
@@ -199,9 +205,9 @@ export class ModSearchRequest {
|
||||
|
||||
// Define the structure for the latest release info from the portal
|
||||
export const ModPortalReleaseSchema = Type.Object({
|
||||
version: Type.String(),
|
||||
version: FullVersionSchema,
|
||||
// Match the structure from ModStore's ModRelease/ModDetails
|
||||
info_json: Type.Object({ factorio_version: Type.String() }),
|
||||
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
|
||||
released_at: Type.String(), // ISO 8601 date string
|
||||
download_url: Type.String(),
|
||||
file_name: Type.String(),
|
||||
@@ -229,20 +235,13 @@ export class ModPortalGetAllRequest {
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
// Define allowed Factorio versions
|
||||
static allowedVersions = [
|
||||
Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"),
|
||||
Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"),
|
||||
Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"),
|
||||
] as const;
|
||||
|
||||
constructor(
|
||||
public factorioVersion: Static<typeof ModPortalGetAllRequest.allowedVersions[number]>,
|
||||
public factorioVersion: ApiVersion,
|
||||
public hide_deprecated?: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"hide_deprecated": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -277,13 +276,13 @@ export class ModDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -303,12 +302,12 @@ export class ModDeleteRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public version: FullVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -356,6 +355,16 @@ export class ModUpdatesEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModNameVersionPair {
|
||||
name: string,
|
||||
version: ModVersionEquality,
|
||||
}
|
||||
|
||||
export const ModNameVersionPairSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": ModVersionEquality.jsonSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Request mods to be downloaded from the Factorio mod portal to the controller.
|
||||
*
|
||||
@@ -363,8 +372,7 @@ export class ModUpdatesEvent {
|
||||
* Requires Factorio credentials to be configured on the controller if the portal
|
||||
* requires authentication for downloads.
|
||||
*
|
||||
* @param name - Name of the mod to download.
|
||||
* @param version - Version of the mod to download.
|
||||
* @param mods - Array of mods to be downloaded.
|
||||
* @param factorioVersion - Factorio version context for the download.
|
||||
*/
|
||||
export class ModPortalDownloadRequest {
|
||||
@@ -373,21 +381,93 @@ export class ModPortalDownloadRequest {
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.download_from_portal" as const;
|
||||
static Response = ModInfo;
|
||||
static Response = jsonArray(ModInfo);
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public factorioVersion: string,
|
||||
public mods: ModNameVersionPair[],
|
||||
public factorioVersion: ApiVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"factorioVersion": Type.String(),
|
||||
"mods": Type.Array(ModNameVersionPairSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.version, json.factorioVersion);
|
||||
return new this(
|
||||
json.mods.map(mod => ({
|
||||
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
|
||||
})),
|
||||
json.factorioVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request mods dependencies to be resolved using the Factorio mod portal.
|
||||
*
|
||||
* @param mods - Array of dependencies to resolve.
|
||||
* @param factorioVersion - Factorio version context for resolution.
|
||||
*/
|
||||
export class ModDependencyResolveRequest {
|
||||
declare ["constructor"]: typeof ModDependencyResolveRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public mods: ModDependency[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public checkForUpdates: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModDependency.jsonSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"checkForUpdates": Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
|
||||
}
|
||||
|
||||
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
|
||||
const equality = checkForUpdates ? ">=" : "=";
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
|
||||
const equality = checkForUpdates ? ">=" : "=";
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.filter(mod => mod.enabled)
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
static Response = class ModDependencyResolveResponse {
|
||||
constructor(
|
||||
public dependencies: ModInfo[],
|
||||
public incompatible: string[],
|
||||
public missing: string[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"dependencies": Type.Array(ModInfo.jsonSchema),
|
||||
"incompatible": Type.Array(Type.String()),
|
||||
"missing": Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonNumber, jsonArray } from "./composites";
|
||||
import Permission from "./Permission";
|
||||
import Role from "./Role";
|
||||
|
||||
export class PermissionListRequest {
|
||||
declare ["constructor"]: typeof PermissionListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.permission.list" as const;
|
||||
static Response = jsonArray(Permission);
|
||||
}
|
||||
|
||||
export class RoleListRequest {
|
||||
declare ["constructor"]: typeof RoleListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.list" as const;
|
||||
static Response = jsonArray(Role);
|
||||
}
|
||||
|
||||
export class RoleUpdatesEvent {
|
||||
declare ["constructor"]: typeof RoleUpdatesEvent;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "control" as const;
|
||||
static permission = "core.role.subscribe" as const;
|
||||
|
||||
constructor(
|
||||
public updates: Role[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(Role.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => Role.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleCreateRequest {
|
||||
declare ["constructor"]: typeof RoleCreateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.create" as const;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.description, json.permissions);
|
||||
}
|
||||
|
||||
static Response = JsonNumber;
|
||||
}
|
||||
|
||||
export class RoleUpdateRequest {
|
||||
declare ["constructor"]: typeof RoleUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.name, json.description, json.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleGrantDefaultPermissionsRequest {
|
||||
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleDeleteRequest {
|
||||
declare ["constructor"]: typeof RoleDeleteRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.delete" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,8 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import Permission from "./Permission";
|
||||
import Role from "./Role";
|
||||
import User, { IControllerUser } from "./User";
|
||||
import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites";
|
||||
import { StringEnum, jsonArray, plainJson } from "./composites";
|
||||
import { MessageRequest } from "./messages_core";
|
||||
|
||||
export class PermissionListRequest {
|
||||
declare ["constructor"]: typeof PermissionListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.permission.list" as const;
|
||||
static Response = jsonArray(Permission);
|
||||
}
|
||||
|
||||
export class RoleListRequest {
|
||||
declare ["constructor"]: typeof RoleListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.list" as const;
|
||||
static Response = jsonArray(Role);
|
||||
}
|
||||
|
||||
export class RoleCreateRequest {
|
||||
declare ["constructor"]: typeof RoleCreateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.create" as const;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.description, json.permissions);
|
||||
}
|
||||
|
||||
static Response = JsonNumber;
|
||||
}
|
||||
|
||||
export class RoleUpdateRequest {
|
||||
declare ["constructor"]: typeof RoleUpdateRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
public description: string,
|
||||
public permissions: string[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
name: Type.String(),
|
||||
description: Type.String(),
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id, json.name, json.description, json.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleGrantDefaultPermissionsRequest {
|
||||
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.update" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleDeleteRequest {
|
||||
declare ["constructor"]: typeof RoleDeleteRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.role.delete" as const;
|
||||
|
||||
constructor(
|
||||
public id: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserGetRequest {
|
||||
declare ["constructor"]: typeof UserGetRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -1,33 +1,241 @@
|
||||
// Helper functions for dealing with factorio version numbers
|
||||
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Integer representation of a mod version
|
||||
* Integer representation of a version string.
|
||||
*
|
||||
* Suitable for sorting and comparing version numbers which may have different
|
||||
* number of leading zeros in them.
|
||||
* @param version - string representation of version.
|
||||
* @returns numeric representation.
|
||||
*/
|
||||
export function integerModVersion(version: string) {
|
||||
export type IntegerVersion = number & { [integerVersionSymbol]: void };
|
||||
declare const integerVersionSymbol: unique symbol;
|
||||
|
||||
/**
|
||||
* Allowed version equalities as defined within mod info json
|
||||
* https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
|
||||
*/
|
||||
const versionEqualities = ["<", "<=", "=", ">=", ">"] as const;
|
||||
export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v)));
|
||||
export type VersionEquality = Static<typeof VersionEqualitySchema>;
|
||||
|
||||
export function isVersionEquality(input: string): input is VersionEquality {
|
||||
return versionEqualities.includes(input as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions accepts by the web API
|
||||
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
|
||||
*/
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
|
||||
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
|
||||
export type ApiVersion = Static<typeof ApiVersionSchema>;
|
||||
|
||||
export function isApiVersion(input: string): input is ApiVersion {
|
||||
return ApiVersions.includes(input as any);
|
||||
}
|
||||
|
||||
export function normaliseApiVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
const apiVersion = `${parts[0]}.${parts[1]}`;
|
||||
if (!isApiVersion(apiVersion)) {
|
||||
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
|
||||
}
|
||||
return apiVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid mod versions, where all parts are specified.
|
||||
*/
|
||||
const fullVersionRegExp = /^\d+\.\d+\.\d+$/;
|
||||
export type FullVersion = `${number}.${number}.${number}`;
|
||||
export const FullVersionSchema = Type.Unsafe<FullVersion>(
|
||||
Type.String({ pattern: fullVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isFullVersion(input: string): input is FullVersion {
|
||||
return fullVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerFullVersion(version: FullVersion) {
|
||||
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
|
||||
// Can't use bitwise here because this is 48-bits.
|
||||
return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion;
|
||||
}
|
||||
|
||||
export function normaliseFullVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer representation of a factorio version
|
||||
*
|
||||
* Suitable for sorting and comparing version numbers which may have different
|
||||
* number of leading zeros in them.
|
||||
* @param version - string representation of version.
|
||||
* @returns numeric representation.
|
||||
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
|
||||
*/
|
||||
export function integerFactorioVersion(version: string) {
|
||||
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
|
||||
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
|
||||
export type PartialVersion = FullVersion | `${number}.${number}`;
|
||||
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
|
||||
Type.String({ pattern: partialVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isPartialVersion(input: string): input is PartialVersion {
|
||||
return partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerPartialVersion(version: PartialVersion) {
|
||||
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
// Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0.
|
||||
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid mod versions.
|
||||
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
|
||||
*/
|
||||
export const modVersionRegExp = /^\d+\.\d+\.\d+$/;
|
||||
export type TargetVersion = PartialVersion | "latest";
|
||||
export const TargetVersionSchema = Type.Union([
|
||||
PartialVersionSchema,
|
||||
Type.Literal("latest"),
|
||||
]);
|
||||
|
||||
export function isTargetVersion(input: string): input is TargetVersion {
|
||||
return input === "latest" || partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a mod version paired with an equality which can be tested against
|
||||
*/
|
||||
export class ModVersionEquality {
|
||||
public integerVersion: IntegerVersion;
|
||||
|
||||
constructor(
|
||||
public equality: VersionEquality,
|
||||
public version: PartialVersion,
|
||||
) {
|
||||
this.integerVersion = integerPartialVersion(version);
|
||||
if (!isVersionEquality(this.equality)) {
|
||||
throw new Error("Invalid equality");
|
||||
}
|
||||
if (!isPartialVersion(this.version)) {
|
||||
throw new Error("Invalid version");
|
||||
}
|
||||
}
|
||||
|
||||
testIntegerVersion(other: IntegerVersion) {
|
||||
switch (this.equality) {
|
||||
case "<":
|
||||
return other < this.integerVersion;
|
||||
case "<=":
|
||||
return other <= this.integerVersion;
|
||||
case "=":
|
||||
return other === this.integerVersion;
|
||||
case ">=":
|
||||
return other >= this.integerVersion;
|
||||
case ">":
|
||||
return other > this.integerVersion;
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${this.equality} ${this.version}`;
|
||||
}
|
||||
|
||||
static fromString(version: string) {
|
||||
const parts = version.split(" ");
|
||||
return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
static fromParts(equality: string, version: string) {
|
||||
if (!isVersionEquality(equality)) {
|
||||
throw new Error(`Unknown version equality: ${equality}`);
|
||||
}
|
||||
if (!isPartialVersion(version)) {
|
||||
throw new Error(`Malformed version string: ${version}`);
|
||||
}
|
||||
return new this(equality, version);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return this.fromString(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a range of mod versions which can be tested against
|
||||
*/
|
||||
export class ModVersionRange {
|
||||
constructor(
|
||||
public minVersion = new ModVersionEquality(">=", "0.0.0"),
|
||||
public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"),
|
||||
) {
|
||||
if (minVersion.equality === "<" || minVersion.equality === "<=") {
|
||||
throw new Error("Minimum version can not use < or <=");
|
||||
}
|
||||
if (maxVersion.equality === ">" || maxVersion.equality === ">=") {
|
||||
throw new Error("Maximum version can not use > or >=");
|
||||
}
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion)
|
||||
&& this.maxVersion.testIntegerVersion(this.minVersion.integerVersion);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.maxVersion = new ModVersionEquality(">", "0.0.0");
|
||||
}
|
||||
|
||||
testIntegerVersion(other: IntegerVersion) {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
switch (other.equality) {
|
||||
case "<":
|
||||
if (other.integerVersion <= this.maxVersion.integerVersion) {
|
||||
this.maxVersion = other;
|
||||
}
|
||||
break;
|
||||
case "<=":
|
||||
if (other.integerVersion < this.maxVersion.integerVersion) {
|
||||
this.maxVersion = other;
|
||||
}
|
||||
break;
|
||||
case "=":
|
||||
if (this.testIntegerVersion(other.integerVersion)) {
|
||||
this.minVersion = other;
|
||||
this.maxVersion = other;
|
||||
} else {
|
||||
// Make invalid as it could not be satisfied
|
||||
this.invalidate();
|
||||
}
|
||||
break;
|
||||
case ">=":
|
||||
if (other.integerVersion > this.minVersion.integerVersion) {
|
||||
this.minVersion = other;
|
||||
}
|
||||
break;
|
||||
case ">":
|
||||
if (other.integerVersion >= this.minVersion.integerVersion) {
|
||||
this.minVersion = other;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
import { SubscribableValue } from "./subscriptions";
|
||||
import { logger } from "./logging";
|
||||
import { EventEmitter } from "stream";
|
||||
import { ControllerConfig } from "./config";
|
||||
import path from "path";
|
||||
|
||||
type DatastoreKey = string | number;
|
||||
type DatastoreValue = string | number | boolean | object;
|
||||
@@ -30,7 +30,7 @@ export class MemoryDatastoreProvider<
|
||||
V extends DatastoreValue,
|
||||
> extends DatastoreProvider<K, V> {
|
||||
constructor(
|
||||
private value: Map<K, V> = new Map()
|
||||
private value = new Map<K, V>()
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -39,34 +39,33 @@ export class MemoryDatastoreProvider<
|
||||
this.value = new Map(data);
|
||||
}
|
||||
|
||||
async load() {
|
||||
async load(): Promise<Map<K, V>> {
|
||||
return new Map(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Store all data within a json object file and validate the result against a schema
|
||||
export class JsonDatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
J, // Intermediate type returned from migrations
|
||||
> extends DatastoreProvider<K, V> {
|
||||
> extends DatastoreProvider<string, V> {
|
||||
constructor(
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any,
|
||||
private migrations: (rawJson: Record<DatastoreKey, unknown>) => Record<DatastoreKey, J> = v => v as any,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Save the data to the json file
|
||||
async save(data: Map<K, V>) {
|
||||
async save(data: Map<string, V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await safeOutputFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
async load() {
|
||||
async load(): Promise<Map<string, V>> {
|
||||
// Read the raw json from the file
|
||||
let rawJson;
|
||||
try {
|
||||
@@ -89,27 +88,26 @@ export class JsonDatastoreProvider<
|
||||
|
||||
// Store all data within a json array file where each element has a unique id
|
||||
export class JsonIdDatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue & { id: K },
|
||||
V extends DatastoreValue & { id: DatastoreKey },
|
||||
J, // Intermediate type returned from migrations
|
||||
> extends DatastoreProvider<K, V> {
|
||||
> extends DatastoreProvider<V["id"], V> {
|
||||
constructor(
|
||||
private filePath: string,
|
||||
private fromJson: (json: J) => V = v => v as any,
|
||||
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as any,
|
||||
private fromJson: (json: J) => V = v => v as any as V,
|
||||
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
|
||||
private finalise: (obj: V) => V = v => v,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Save the data to the json file
|
||||
async save(data: Map<K, V>) {
|
||||
async save(data: Map<V["id"], V>) {
|
||||
logger.verbose(`Saving ${this.filePath}`);
|
||||
await safeOutputFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
|
||||
}
|
||||
|
||||
// Load the data from the json file
|
||||
async load() {
|
||||
async load(): Promise<Map<V["id"], V>> {
|
||||
// Read the raw json from the file
|
||||
let rawJson;
|
||||
try {
|
||||
@@ -137,7 +135,10 @@ export class JsonIdDatastoreProvider<
|
||||
export abstract class Datastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends EventEmitter {
|
||||
U = V,
|
||||
> extends EventEmitter<{
|
||||
"update": [ updates: U[] ],
|
||||
}> {
|
||||
private dirty = false;
|
||||
|
||||
constructor(
|
||||
@@ -167,7 +168,7 @@ export abstract class Datastore<
|
||||
}
|
||||
|
||||
// Set the dirty flag and call update handlers
|
||||
protected touch(updates: any[]) {
|
||||
protected touch(updates: U[]) {
|
||||
if (updates.length) {
|
||||
this.dirty = true;
|
||||
this.emit("update", updates);
|
||||
@@ -208,13 +209,17 @@ export abstract class Datastore<
|
||||
valuesMutable() {
|
||||
return this.data.values();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.data.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// General key value mapping which can be saved and loaded with the file system
|
||||
export class KeyValueDatastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends Datastore<K, V> {
|
||||
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
|
||||
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
|
||||
set(key: K, value: V) {
|
||||
this.data.set(key, value);
|
||||
@@ -232,8 +237,10 @@ export class KeyValueDatastore<
|
||||
// Delete a value in the datastore
|
||||
delete(key: K) {
|
||||
const value = this.data.get(key);
|
||||
this.data.delete(key);
|
||||
this.touch([[key, value!, true]]);
|
||||
if (value !== undefined) {
|
||||
this.data.delete(key);
|
||||
this.touch([[key, value, true]]);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete many values at once from an array of keys
|
||||
|
||||
@@ -414,7 +414,7 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
buf = Buffer.from(zlib.inflateSync(buf));
|
||||
} catch (err: any) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed map exchange string: zlib inflate failed");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable node/global-require */
|
||||
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
// eslint-disable-next-line node/no-extraneous-require
|
||||
|
||||
module.exports = require("fast-deep-equal/es6");
|
||||
} else {
|
||||
module.exports = require("util").isDeepStrictEqual;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
module.exports = process.env.APP_ENV === "browser" ? window.WebSocket : require("ws");
|
||||
@@ -1,8 +1,8 @@
|
||||
// Connection adapters for Links
|
||||
import { strict as assert } from "assert";
|
||||
import events from "events";
|
||||
import WebSocket from "./WebSocket";
|
||||
|
||||
import WebSocket from "./WebSocket";
|
||||
import * as libData from "../data";
|
||||
import * as libErrors from "../errors";
|
||||
import { logger } from "../logging";
|
||||
@@ -36,12 +36,21 @@ export enum ConnectionClosed {
|
||||
RecoveryMode = 4001,
|
||||
};
|
||||
|
||||
type BaseConnectorEvents = {
|
||||
"message": [ message: libData.Message ],
|
||||
"error": [ err: any ],
|
||||
"invalidate": [],
|
||||
"reset": [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Base connector for links
|
||||
*
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export abstract class BaseConnector extends events.EventEmitter {
|
||||
export abstract class BaseConnector<
|
||||
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
|
||||
> extends events.EventEmitter<E | BaseConnectorEvents> {
|
||||
protected _seq = 1;
|
||||
|
||||
constructor(
|
||||
@@ -53,10 +62,12 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
|
||||
_reset() {
|
||||
this._seq = 1;
|
||||
this.emit("reset");
|
||||
}
|
||||
|
||||
_invalidate() {
|
||||
this._seq = 1;
|
||||
this.emit("invalidate");
|
||||
}
|
||||
|
||||
protected abstract send(message: libData.MessageRoutable): void;
|
||||
@@ -131,6 +142,15 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
|
||||
type ConnectorState = "closed" | "connecting" | "connected" | "resuming";
|
||||
|
||||
type WebSocketBaseConnectorEvents = {
|
||||
"disconnectReady": [],
|
||||
"disconnectPrepare": [],
|
||||
"connect": [ data: any ],
|
||||
"resume": [],
|
||||
"close": [],
|
||||
"drop": [],
|
||||
};
|
||||
|
||||
export type WebSocketClusterio = WebSocket.WebSocket & {
|
||||
clusterio_ignore_dump?: boolean;
|
||||
};
|
||||
@@ -140,7 +160,9 @@ export type WebSocketClusterio = WebSocket.WebSocket & {
|
||||
*
|
||||
* @extends module:lib.BaseConnector
|
||||
*/
|
||||
export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
export abstract class WebSocketBaseConnector<
|
||||
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
|
||||
> extends BaseConnector<E | WebSocketBaseConnectorEvents> {
|
||||
// One of closed, connecting (client only), connected and resuming.
|
||||
_state: ConnectorState = "closed";
|
||||
_closing = false;
|
||||
@@ -166,7 +188,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
_invalidate() {
|
||||
this._state = "connecting";
|
||||
assert(this._closing === false);
|
||||
assert(this._socket);
|
||||
// We do not care about the socket state
|
||||
this._lastHeartbeatMs = null;
|
||||
assert(this._heartbeatId === null);
|
||||
this._heartbeatInterval = null;
|
||||
@@ -175,6 +197,11 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
super._invalidate();
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
_check(...expectedStates: ConnectorState[]) {
|
||||
if (!expectedStates.includes(this._state)) {
|
||||
throw new Error(
|
||||
@@ -244,7 +271,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
this._check("connected");
|
||||
if (Date.now() - this._lastHeartbeatMs! > 2000 * this._heartbeatInterval!) {
|
||||
logger.verbose("Connector | closing after heartbeat timed out");
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket!.close(ConnectionClosed.Timeout, "Heartbeat timeout");
|
||||
} else {
|
||||
@@ -385,12 +412,16 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
}
|
||||
}
|
||||
|
||||
type WebSocketClientConnectorEvents = {
|
||||
"hello": [ data: any ],
|
||||
};
|
||||
|
||||
/**
|
||||
* Connector for controller clients
|
||||
*
|
||||
* @extends module:lib.WebSocketBaseConnector
|
||||
*/
|
||||
export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
export abstract class WebSocketClientConnector extends WebSocketBaseConnector<WebSocketClientConnectorEvents> {
|
||||
_reconnectId?: ReturnType<typeof setTimeout>;
|
||||
_sessionToken: string | null = null;
|
||||
_sessionTimeout: number | null = null;
|
||||
@@ -418,8 +449,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResumingMs = null;
|
||||
super._reset();
|
||||
this.src = undefined as any;
|
||||
super._reset();
|
||||
}
|
||||
|
||||
_invalidate() {
|
||||
@@ -427,8 +458,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResumingMs = null;
|
||||
super._invalidate();
|
||||
this.src = undefined as any;
|
||||
super._invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,8 +494,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
await events.once(this, "close");
|
||||
|
||||
} else {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,8 +519,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._socket!.close(ConnectionClosed.NormalClosure, "Connector closing");
|
||||
|
||||
} else {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +544,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
// Open WebSocket to controller
|
||||
logger.verbose(`Connector | connecting to ${url}`);
|
||||
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket = new WebSocket(url) as WebSocketClusterio;
|
||||
|
||||
@@ -546,13 +575,10 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
&& this._startedResumingMs! + this._sessionTimeout! * 1000 < Date.now() + delayMs
|
||||
) {
|
||||
logger.error("Connector | Session timed out trying to resume");
|
||||
this._reset();
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
} else {
|
||||
this._state = "connecting";
|
||||
this.emit("invalidate");
|
||||
this._invalidate();
|
||||
}
|
||||
}
|
||||
logger.verbose(
|
||||
@@ -604,8 +630,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
} else {
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
|
||||
} else {
|
||||
this.reconnect();
|
||||
@@ -711,11 +736,9 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
} else if (type === "invalidate") {
|
||||
logger.warn("Connector | session invalidated by controller");
|
||||
if (this._closing) {
|
||||
this._reset();
|
||||
this.emit("close");
|
||||
this._close();
|
||||
} else {
|
||||
this._invalidate();
|
||||
this.emit("invalidate");
|
||||
this.register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class Link {
|
||||
// Process messages received by the connector
|
||||
connector.on("message", payload => {
|
||||
try {
|
||||
this._processMessage(payload);
|
||||
this._processMessage(payload as MessageRoutable);
|
||||
} catch (err) {
|
||||
if (err instanceof libErrors.InvalidMessage) {
|
||||
logger.error(err.message);
|
||||
@@ -128,10 +128,10 @@ export class Link {
|
||||
}
|
||||
});
|
||||
|
||||
// The events below exist only on websocket connectors, we can't early return because of test mocking
|
||||
const webSocketConnector = this.connector as WebSocketBaseConnector;
|
||||
connector.on("disconnectPrepare", () => {
|
||||
this.prepareDisconnect().finally(() => {
|
||||
// Only WebSocket connectors issue disconnection events.
|
||||
const webSocketConnector = this.connector as WebSocketBaseConnector;
|
||||
if (webSocketConnector.hasSession) {
|
||||
webSocketConnector.sendDisconnectReady();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as host from "../data/messages_host";
|
||||
import * as instance from "../data/messages_instance";
|
||||
import * as mod from "../data/messages_mod";
|
||||
import * as user from "../data/messages_user";
|
||||
import * as role from "../data/messages_role";
|
||||
import * as plugin from "../data/messages_plugin";
|
||||
import * as subscriptions from "../subscriptions";
|
||||
import type { RequestClass, EventClass } from "./link";
|
||||
@@ -62,6 +63,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAssignRequest,
|
||||
instance.InstanceMetricsRequest,
|
||||
instance.InstanceStartRequest,
|
||||
instance.InstanceRestartRequest,
|
||||
instance.InstanceSaveDetailsListRequest,
|
||||
instance.InstanceSaveDetailsUpdatesEvent,
|
||||
instance.InstanceCreateSaveRequest,
|
||||
@@ -106,13 +108,16 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
mod.ModPackUpdatesEvent,
|
||||
mod.ModUpdatesEvent,
|
||||
mod.ModPortalDownloadRequest,
|
||||
mod.ModDependencyResolveRequest,
|
||||
|
||||
role.PermissionListRequest,
|
||||
role.RoleListRequest,
|
||||
role.RoleUpdatesEvent,
|
||||
role.RoleCreateRequest,
|
||||
role.RoleUpdateRequest,
|
||||
role.RoleGrantDefaultPermissionsRequest,
|
||||
role.RoleDeleteRequest,
|
||||
|
||||
user.PermissionListRequest,
|
||||
user.RoleListRequest,
|
||||
user.RoleCreateRequest,
|
||||
user.RoleUpdateRequest,
|
||||
user.RoleGrantDefaultPermissionsRequest,
|
||||
user.RoleDeleteRequest,
|
||||
user.UserGetRequest,
|
||||
user.UserListRequest,
|
||||
user.UserCreateRequest,
|
||||
|
||||
@@ -519,7 +519,7 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
|
||||
}
|
||||
|
||||
export function handleUnhandledErrors() {
|
||||
/* eslint-disable node/no-process-exit */
|
||||
|
||||
process.on("uncaughtException", err => {
|
||||
logger.fatal(`Uncaught exception:\n${err.stack}`);
|
||||
process.exit(1);
|
||||
@@ -528,7 +528,7 @@ export function handleUnhandledErrors() {
|
||||
logger.fatal(`Unhandled rejection:\n${reason.stack ? reason.stack : reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
/* eslint-enable node/no-process-exit */
|
||||
|
||||
}
|
||||
|
||||
// for testing only
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* @module lib/users
|
||||
*/
|
||||
import { Permission, Role } from "./data";
|
||||
import { SubscribableDatastore } from "./datastore";
|
||||
|
||||
|
||||
export const permissions = new Map<string, Permission>();
|
||||
@@ -66,7 +67,7 @@ export function definePermission({
|
||||
* @param roles - role storage to modify.
|
||||
* @returns the default admin role
|
||||
*/
|
||||
export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
export function ensureDefaultAdminRole(roles: SubscribableDatastore<Role>) {
|
||||
let admin = roles.get(0);
|
||||
if (!admin) {
|
||||
admin = Role.fromJSON({
|
||||
@@ -75,9 +76,9 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
description: "Cluster wide administrator.",
|
||||
permissions: [],
|
||||
});
|
||||
roles.set(0, admin);
|
||||
}
|
||||
admin.permissions.add("core.admin");
|
||||
roles.set(admin);
|
||||
return admin;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
|
||||
*
|
||||
* @param roles - role storage to modify.
|
||||
*/
|
||||
export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
|
||||
export function ensureDefaultPlayerRole(roles: SubscribableDatastore<Role>) {
|
||||
let player = roles.get(1);
|
||||
if (!player) {
|
||||
player = Role.fromJSON({
|
||||
@@ -99,9 +100,9 @@ export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
|
||||
description: "Default player role.",
|
||||
permissions: [],
|
||||
});
|
||||
roles.set(1, player);
|
||||
}
|
||||
player.grantDefaultPermissions();
|
||||
roles.set(player);
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +307,11 @@ definePermission({
|
||||
title: "Start instance",
|
||||
description: "Start instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.restart",
|
||||
title: "Restart instance",
|
||||
description: "Restart instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.load_scenario",
|
||||
title: "Load scenario",
|
||||
@@ -431,6 +437,12 @@ definePermission({
|
||||
description: "Get the full list of roles and their permissions in the cluster.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.role.subscribe",
|
||||
title: "Subscribe to role updates",
|
||||
description: "Subscribe to be notified on updates on the details of roles.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
definePermission({
|
||||
name: "core.role.create",
|
||||
title: "Create role",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
/**
|
||||
* Helpers for loading plugins in Node.js
|
||||
* @module lib/plugin_loader
|
||||
*/
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import * as libErrors from "./errors";
|
||||
import * as libPlugin from "./plugin";
|
||||
import { logger } from "./logging";
|
||||
@@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
// Check if plugin path exists, otherwise remove it
|
||||
try {
|
||||
require.resolve(pluginPath);
|
||||
} catch (err) {
|
||||
logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`);
|
||||
} catch {
|
||||
let errMsg = `Plugin path ${pluginPath} does not exist`;
|
||||
try {
|
||||
await fs.access(pluginPath, fs.constants.F_OK);
|
||||
errMsg = `Plugin path ${pluginPath} missing index or main file`;
|
||||
} catch {}
|
||||
logger.error(`${errMsg}, not loading ${pluginName}`);
|
||||
pluginList.delete(pluginName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function handlePluginCommand(
|
||||
|
||||
let pluginInfo: { name: string };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
pluginInfo = require(pluginPath).plugin;
|
||||
} catch (err: any) {
|
||||
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
|
||||
|
||||
@@ -39,30 +39,45 @@ export class SubscriptionRequest {
|
||||
public eventName: string,
|
||||
public subscribe: boolean,
|
||||
public lastRequestTimeMs: number = 0,
|
||||
public filters: string | string[] | undefined = undefined,
|
||||
) {
|
||||
if (!Link._eventsByName.has(eventName)) {
|
||||
throw new Error(`Unregistered Event class ${eventName}`);
|
||||
}
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
static jsonSchema = Type.Union([
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
]),
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
Type.Union([Type.String(), Type.Array(Type.String())]),
|
||||
]),
|
||||
]);
|
||||
|
||||
toJSON() {
|
||||
if (this.filters !== undefined) {
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
|
||||
}
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
|
||||
return new this(...json);
|
||||
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
|
||||
return new this(eventName, subscribe, lastRequestTimeMs, filters);
|
||||
}
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
link: Link,
|
||||
dst: Address,
|
||||
/** Optional filters this subscriber is interested in. Undefined means all events */
|
||||
filters?: string[],
|
||||
}
|
||||
|
||||
type EventData = {
|
||||
@@ -77,6 +92,13 @@ type EventData = {
|
||||
export class SubscriptionController {
|
||||
_events = new Map<string, EventData>();
|
||||
|
||||
private static normalizeFilters(value?: string | string[]): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow clients to subscribe to an event by telling the subscription controller to accept them
|
||||
* Has an optional subscription update handler which is called when a client subscribes
|
||||
@@ -102,7 +124,7 @@ export class SubscriptionController {
|
||||
* Broadcast an event to all subscribers of that event
|
||||
* @param event - Event to broadcast.
|
||||
*/
|
||||
broadcast<T>(event: Event<T>) {
|
||||
broadcast<T>(event: Event<T>, filter?: string | string[]) {
|
||||
const entry = Link._eventsByClass.get(event.constructor);
|
||||
if (!entry) {
|
||||
throw new Error(`Unregistered Event class ${event.constructor.name}`);
|
||||
@@ -112,10 +134,19 @@ export class SubscriptionController {
|
||||
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
|
||||
}
|
||||
const toRemove: Subscriber[] = [];
|
||||
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
|
||||
|
||||
for (const subscriber of eventData.subscriptions) {
|
||||
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
|
||||
toRemove.push(subscriber);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
filtersToMatch === undefined
|
||||
|| subscriber.filters === undefined
|
||||
|| subscriber.filters.some(f => filtersToMatch.includes(f))
|
||||
) {
|
||||
subscriber.link.sendTo(subscriber.dst, event);
|
||||
}
|
||||
}
|
||||
@@ -164,13 +195,37 @@ export class SubscriptionController {
|
||||
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
|
||||
}
|
||||
if (event.subscribe === false) {
|
||||
const index = eventData.subscriptions.findLastIndex(subscriber => subscriber.dst.addressedTo(src));
|
||||
if (index >= 0) {
|
||||
eventData.subscriptions.splice(index, 1);
|
||||
// Unsubscribe logic
|
||||
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
if (!subscriber) {
|
||||
return false;
|
||||
}
|
||||
if (event.filters === undefined || subscriber.filters === undefined) {
|
||||
// Remove entire subscription for this address
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src));
|
||||
} else {
|
||||
const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters];
|
||||
subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f));
|
||||
if (subscriber.filters.length === 0) {
|
||||
// No filters left, remove subscription
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!eventData.subscriptions.some(subscriber => subscriber.dst.addressedTo(src))) {
|
||||
eventData.subscriptions.push({ link: link, dst: src });
|
||||
// Subscribe logic
|
||||
let subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
const newFilters = SubscriptionController.normalizeFilters(event.filters);
|
||||
if (!subscriber) {
|
||||
eventData.subscriptions.push({ link: link, dst: src, filters: newFilters });
|
||||
} else if (subscriber.filters === undefined || newFilters === undefined) {
|
||||
// Either was already subscribed to all or new request wants all
|
||||
subscriber.filters = undefined;
|
||||
} else {
|
||||
for (const f of newFilters) {
|
||||
if (!subscriber.filters.includes(f)) {
|
||||
subscriber.filters.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eventData.subscriptionUpdate) {
|
||||
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
|
||||
@@ -222,10 +277,12 @@ export class EventSubscriber<
|
||||
public control: Link,
|
||||
) {
|
||||
control.handle(Event, this._handleEvent.bind(this));
|
||||
control.connector.on("connect", () => {
|
||||
// The events below exist only on websocket connectors, we can't early return because of test mocking
|
||||
const webSocketConnector = control.connector as WebSocketBaseConnector;
|
||||
webSocketConnector.on("connect", () => {
|
||||
this.handleConnectionEvent("connect");
|
||||
});
|
||||
control.connector.on("close", () => {
|
||||
webSocketConnector.on("close", () => {
|
||||
this.handleConnectionEvent("close");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"./src/api.ts",
|
||||
"./src/config/*.ts",
|
||||
"./src/data/*.ts",
|
||||
"./src/datastore.ts",
|
||||
"./src/errors.ts",
|
||||
"./src/factorio/exchange_string.ts",
|
||||
"./src/helpers.ts",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.21",
|
||||
"version": "2.0.0-alpha.22",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@swc/core": "^1.11.1",
|
||||
"@swc/core": "^1.13.3",
|
||||
"antd": "^5.24.2",
|
||||
"assert": "^2.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
</>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
|
||||
function computeTreeData(
|
||||
control: Control,
|
||||
form: FormInstance<any>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import { Button, Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
|
||||
import { Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -19,60 +19,78 @@ import PageLayout from "./PageLayout";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import webUiPackage from "../../package.json";
|
||||
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type ControllerControlButtonProps = { canRestart?: boolean, restartRequired?: boolean };
|
||||
|
||||
function ControllerControlButton({ canRestart, restartRequired }: ControllerControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.controller.restart") && canRestart) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: (
|
||||
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
|
||||
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
|
||||
Restart
|
||||
</Space></Tooltip>
|
||||
),
|
||||
onClick: () => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.controller.stop")) {
|
||||
actions.push({
|
||||
key: "stop",
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the controller?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.ControllerStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping controller"));
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
|
||||
export default function ControllerPage() {
|
||||
let account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [systems] = useSystems();
|
||||
const system = systems.get("controller");
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
|
||||
const canShowNpm = hasNpmButtonPermission(true);
|
||||
|
||||
return <PageLayout nav={[{ name: "Controller" }]}>
|
||||
<PageHeader
|
||||
title="Controller"
|
||||
extra={<Space>
|
||||
<ControllerControlButton canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.controller.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping the controller will leave the cluster inoperable until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the controller?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.ControllerStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping controller"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.controller.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.send(
|
||||
new lib.ControllerRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting controller"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }}/>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
hasNpmButtonPermission(true)
|
||||
canShowNpm
|
||||
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
|
||||
}
|
||||
</Space>}
|
||||
|
||||
@@ -22,9 +22,65 @@ import {
|
||||
import { formatTimestamp } from "../util/time_format";
|
||||
import { useSystems } from "../model/system";
|
||||
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type HostControlButtonProps = { hostId: number, canRestart?: boolean, restartRequired?: boolean };
|
||||
|
||||
function HostControlButton({ hostId, canRestart, restartRequired }: HostControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.host.restart") && canRestart) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: (
|
||||
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
|
||||
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
|
||||
Restart
|
||||
</Space></Tooltip>
|
||||
),
|
||||
onClick: () => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting host"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.host.stop")) {
|
||||
actions.push({
|
||||
key: "stop",
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the host?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping host"));
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Popconfirm>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton actions={actions} />;
|
||||
}
|
||||
|
||||
export default function HostViewPage() {
|
||||
let params = useParams();
|
||||
let hostId = Number(params.id);
|
||||
@@ -35,6 +91,7 @@ export default function HostViewPage() {
|
||||
const system = systems.get(hostId);
|
||||
const [host, synced] = useHost(hostId);
|
||||
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
|
||||
const canShowNpm = hasNpmButtonPermission(false);
|
||||
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
|
||||
|
||||
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
|
||||
@@ -50,6 +107,7 @@ export default function HostViewPage() {
|
||||
}
|
||||
|
||||
let hostButtons = <Space>
|
||||
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
|
||||
{
|
||||
account.hasPermission("core.host.revoke_token")
|
||||
&& <Popconfirm
|
||||
@@ -68,49 +126,9 @@ export default function HostViewPage() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.stop")
|
||||
&& <Popconfirm
|
||||
title={<>
|
||||
Stopping this host will leave it inaccessible until someone with
|
||||
access to the system it runs on manually starts it again.<br />
|
||||
Are you sure you want to stop the host?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Stop"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostStopRequest()
|
||||
).catch(notifyErrorHandler("Error stopping host"));
|
||||
}}
|
||||
>
|
||||
<Button danger>Stop</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
{
|
||||
account.hasPermission("core.host.restart")
|
||||
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
|
||||
<Button
|
||||
disabled={system?.canRestart === false}
|
||||
onClick={() => {
|
||||
control.sendTo(
|
||||
{ hostId },
|
||||
new lib.HostRestartRequest()
|
||||
).catch(notifyErrorHandler("Error restarting host"));
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
{!system?.restartRequired ? undefined
|
||||
: <ExclamationCircleOutlined style={{ color: "yellow" }} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
hasNpmButtonPermission(false)
|
||||
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
}
|
||||
{canShowNpm && (
|
||||
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
|
||||
)}
|
||||
</Space>;
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useRoles } from "../model/roles";
|
||||
|
||||
export default function InputRole(props: InputComponentProps) {
|
||||
let control = useContext(ControlContext);
|
||||
let [roles, setRoles] = useState<lib.Role[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
control.send(new lib.RoleListRequest()).then(newRoles => {
|
||||
setRoles(newRoles);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, []);
|
||||
const [roles] = useRoles();
|
||||
|
||||
return <Select
|
||||
showSearch
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Popconfirm } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { useAccount } from "../model/account";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
|
||||
|
||||
type InstanceControlButtonProps = Omit<VariableDropdownButtonProps, "actions"> & {
|
||||
instance: lib.InstanceDetails;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const InstanceControlButtonPermissions = [
|
||||
"core.instance.start", "core.instance.restart", "core.instance.stop", "core.instance.kill",
|
||||
] as const;
|
||||
|
||||
export default function InstanceControlButton(props: InstanceControlButtonProps) {
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [switching, setSwitching] = useState<boolean>(false);
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
const showStart = ["stopped", "stopping"].includes(instanceStatus);
|
||||
const showStop = ["running", "starting"].includes(instanceStatus);
|
||||
|
||||
function actionFinish() {
|
||||
setSwitching(false);
|
||||
if (props.onFinish) {
|
||||
props.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.instance.start") && showStart !== switching) {
|
||||
actions.push({
|
||||
key: "start",
|
||||
label: "Start",
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error starting instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
|
||||
} else if (account.hasPermission("core.instance.stop")) { // else if because it is stop or start
|
||||
actions.push({
|
||||
key: "stop",
|
||||
label: "Stop",
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceStopRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error stopping instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.instance.restart")) {
|
||||
actions.push({
|
||||
key: "restart",
|
||||
label: "Restart",
|
||||
disabled: instanceStatus !== "running",
|
||||
onClick: () => {
|
||||
setSwitching(true);
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceRestartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error restarting instance")
|
||||
).finally(actionFinish);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
actions.push({
|
||||
key: "kill",
|
||||
danger: true,
|
||||
disabled: instanceStatus === "stopped",
|
||||
label: (<Popconfirm
|
||||
title={<>
|
||||
Killing this instance may leave you save and player data corrupted.
|
||||
Are you sure you want to kill the instance?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Kill"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error killing instance")
|
||||
).finally(actionFinish);
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
</Popconfirm>),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Button, Table } from "antd";
|
||||
import { message, Button, Table, Space } from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
import type { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useSystems } from "../model/system";
|
||||
import { useHosts } from "../model/host";
|
||||
import { RestartRequired } from "./system_metrics";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
@@ -25,6 +27,7 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
|
||||
function hostName(hostId?: number) {
|
||||
if (hostId === undefined) {
|
||||
@@ -34,10 +37,10 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
}
|
||||
|
||||
function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) {
|
||||
if (instance.factorioVersion === undefined) {
|
||||
if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") {
|
||||
return -1;
|
||||
}
|
||||
return lib.integerFactorioVersion(instance.factorioVersion);
|
||||
return lib.integerPartialVersion(instance.factorioVersion);
|
||||
}
|
||||
|
||||
let columns: ColumnsType<lib.InstanceDetails> = [
|
||||
@@ -50,12 +53,15 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
{
|
||||
title: "Assigned Host",
|
||||
key: "assignedHost",
|
||||
render: (_, instance) => <Link
|
||||
to={`/hosts/${instance.assignedHost}/view`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{hostName(instance.assignedHost)}
|
||||
</Link>,
|
||||
render: (_, instance) => <Space>
|
||||
<Link
|
||||
to={`/hosts/${instance.assignedHost}/view`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{hostName(instance.assignedHost)}
|
||||
</Link>
|
||||
<RestartRequired system={instance.assignedHost ? systems.get(instance.assignedHost) : undefined}/>
|
||||
</Space>,
|
||||
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
@@ -98,13 +104,10 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
},
|
||||
];
|
||||
|
||||
if (account.hasAnyPermission("core.instance.start", "core.instance.stop")) {
|
||||
if (account.hasAnyPermission(...InstanceControlButtonPermissions)) {
|
||||
columns.push({
|
||||
key: "action",
|
||||
render: (_, instance) => <StartStopInstanceButton
|
||||
buttonProps={{ size: "small" }}
|
||||
instance={instance}
|
||||
/>,
|
||||
render: (_, instance) => <InstanceControlButton size="small" instance={instance} />,
|
||||
responsive: ["sm"],
|
||||
align: "right",
|
||||
width: 100,
|
||||
|
||||
@@ -15,9 +15,10 @@ import InstanceConfigTree from "./InstanceConfigTree";
|
||||
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
import AssignInstanceModal from "./AssignInstanceModal";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
|
||||
import LoadScenarioModal from "./LoadScenarioModal";
|
||||
import SavesList from "./SavesList";
|
||||
import OnlinePlayersList from "./OnlinePlayersList";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useHost } from "../model/host";
|
||||
@@ -91,13 +92,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
label: "Extract players from save",
|
||||
});
|
||||
}
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
instanceButtonMenuItems.push({
|
||||
disabled: ["unknown", "unassigned", "stopped"].includes(instance.status!),
|
||||
key: "kill",
|
||||
label: "Kill process",
|
||||
});
|
||||
}
|
||||
if (account.hasPermission("core.instance.delete")) {
|
||||
if (instanceButtonMenuItems.length) {
|
||||
instanceButtonMenuItems.push({ type: "divider" });
|
||||
@@ -130,12 +124,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
new lib.InstanceExtractPlayersRequest(),
|
||||
).catch(notifyErrorHandler("Error extracting player data"));
|
||||
|
||||
} else if (key === "kill") {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(notifyErrorHandler("Error killing instance"));
|
||||
|
||||
} else if (key === "delete") {
|
||||
Modal.confirm({
|
||||
autoFocusButton: "cancel",
|
||||
@@ -155,8 +143,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
};
|
||||
return <Space>
|
||||
{
|
||||
account.hasAnyPermission("core.instance.start", "core.instance.stop")
|
||||
&& <StartStopInstanceButton instance={instance} />
|
||||
account.hasAnyPermission(...InstanceControlButtonPermissions)
|
||||
&& <InstanceControlButton instance={instance} />
|
||||
}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -173,7 +161,6 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
{account.hasAnyPermission(
|
||||
"core.instance.export_data",
|
||||
"core.instance.extract_players",
|
||||
"core.instance.kill",
|
||||
"core.instance.delete",
|
||||
)
|
||||
&& <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
|
||||
@@ -225,6 +212,8 @@ export default function InstanceViewPage() {
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, Select, Tooltip } from "antd";
|
||||
import { Button, Form, Input, Modal, Select, Space, Tooltip } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -82,11 +82,11 @@ export default function InstancesPage() {
|
||||
return <PageLayout nav={[{ name: "Instances" }]}>
|
||||
<PageHeader
|
||||
title="Instances"
|
||||
extra={<>
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.instance.create") && <CreateInstanceButton instances={instances}/>}
|
||||
{account.hasPermission("core.instance.start")
|
||||
&& <Button onClick={e => instances.forEach(instance => {
|
||||
if (instance.status === "stopped") {
|
||||
if (instance.status === "stopped" && !instance.excludeFromStartAll) {
|
||||
control.sendTo(
|
||||
{ instanceId: instance.id },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
@@ -108,7 +108,7 @@ export default function InstancesPage() {
|
||||
}>
|
||||
Stop all
|
||||
</Button>}
|
||||
</>}
|
||||
</Space>}
|
||||
/>
|
||||
|
||||
<InstanceList instances={instances} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Descriptions, Grid, Typography } from "antd";
|
||||
import { Descriptions, Grid, Tooltip, Typography, Space } from "antd";
|
||||
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
|
||||
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
|
||||
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
|
||||
import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -8,11 +11,25 @@ const { useBreakpoint } = Grid;
|
||||
|
||||
type ModDetailsProps<T> = {
|
||||
mod: T;
|
||||
mods?: lib.ModRecord[];
|
||||
actions?: (mod: T) => React.JSX.Element;
|
||||
};
|
||||
|
||||
export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props: ModDetailsProps<T>) {
|
||||
let screens = useBreakpoint();
|
||||
const mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
|
||||
let mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
|
||||
|
||||
if ("info" in mod) {
|
||||
// Add the mod info if present in a mod record
|
||||
mod = { ...mod, ...mod.info };
|
||||
}
|
||||
|
||||
const depWarnings = new Map<string, lib.ModRecord["warning"]>();
|
||||
if (mod.dependencies && props.mods) {
|
||||
for (const dependency of mod.dependencies) {
|
||||
depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods));
|
||||
}
|
||||
}
|
||||
|
||||
return <Descriptions
|
||||
className="borderless"
|
||||
@@ -52,7 +69,14 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
{Boolean(mod.homepage) && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Internal Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
|
||||
{Boolean(mod.factorioVersion)
|
||||
&& <Descriptions.Item label="Factorio Version">{mod.factorioVersion}</Descriptions.Item>
|
||||
&& <Descriptions.Item label="Factorio Version">
|
||||
<Space>
|
||||
{mod.warning === "wrong_factorio_version" && <Tooltip title="Wrong factorio version.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.factorioVersion}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
}
|
||||
{Boolean(mod.filename) && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
|
||||
{mod.size !== undefined
|
||||
@@ -62,8 +86,22 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
{mod.dependencies && mod.dependencies.length
|
||||
&& <Descriptions.Item label="Dependencies" span={2}>
|
||||
{mod.dependencies
|
||||
.filter(e => !e.startsWith("(?)"))
|
||||
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
|
||||
.filter(e => e.type !== "hidden")
|
||||
.map((e, i) => <Fragment key={i}>
|
||||
<Space>
|
||||
{depWarnings.get(e.name) === "incompatible" && <Tooltip title="Incompatible mod added.">
|
||||
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{depWarnings.get(e.name) === "missing_dependency" && <Tooltip title="Dependency missing.">
|
||||
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{depWarnings.get(e.name) === "wrong_version" && <Tooltip title="Wrong version added.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{e.specification}
|
||||
</Space>
|
||||
<br/>
|
||||
</Fragment>)
|
||||
}
|
||||
</Descriptions.Item>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig,
|
||||
TablePaginationConfig, Alert, notification,
|
||||
} from "antd";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined,
|
||||
CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
|
||||
import ExportOutlined from "@ant-design/icons/ExportOutlined";
|
||||
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
|
||||
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
|
||||
import CloseOutlined from "@ant-design/icons/CloseOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "./ControlContext";
|
||||
@@ -28,16 +28,20 @@ import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
|
||||
const { logger } = lib;
|
||||
const { Paragraph, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
type ModChange =
|
||||
{
|
||||
type: "name" | "description" | "factorioVersion",
|
||||
type: "name" | "description"
|
||||
name?: never,
|
||||
scope?: never,
|
||||
value: string,
|
||||
} | {
|
||||
type: "factorioVersion",
|
||||
name?: never,
|
||||
scope?: never,
|
||||
value: lib.PartialVersion,
|
||||
} | {
|
||||
type: "settings.set" | "settings.delete",
|
||||
name: string,
|
||||
@@ -47,7 +51,7 @@ type ModChange =
|
||||
type: "mods.set" | "mods.delete",
|
||||
name: string,
|
||||
scope?: never,
|
||||
value: lib.ModRecord | lib.ModInfo,
|
||||
value: lib.ModRecord,
|
||||
}
|
||||
;
|
||||
|
||||
@@ -71,28 +75,38 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
|
||||
const [modResultCount, setModResultCount] = useState<number>(2);
|
||||
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
|
||||
const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join(".");
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setFactorioVersion(null);
|
||||
}
|
||||
}, [props.modPack.factorioVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
control.send(new lib.ModSearchRequest(
|
||||
searchText,
|
||||
factorioVersion,
|
||||
modResultPage,
|
||||
modResultPageSize,
|
||||
modResultSort,
|
||||
modResultSortOrder,
|
||||
)).then(response => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
// In React < v18 this causes 3 renders and a partial state necessitating the use
|
||||
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
|
||||
setModResults(response.results);
|
||||
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
|
||||
setModResultCount(response.resultCount);
|
||||
});
|
||||
if (factorioVersion) {
|
||||
control.send(new lib.ModSearchRequest(
|
||||
searchText,
|
||||
factorioVersion,
|
||||
modResultPage,
|
||||
modResultPageSize,
|
||||
modResultSort,
|
||||
modResultSortOrder,
|
||||
)).then(response => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
// In React < v18 this causes 3 renders and a partial state necessitating the use
|
||||
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
|
||||
setModResults(response.results);
|
||||
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
|
||||
setModResultCount(response.resultCount);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
@@ -239,19 +253,260 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
</>;
|
||||
}
|
||||
|
||||
type DownloadDependenciesProps = {
|
||||
disabled?: boolean;
|
||||
checkForUpdates?: boolean;
|
||||
modPack: lib.ModPack;
|
||||
mods: lib.ModRecord[];
|
||||
onChange: (change: ModChange) => void;
|
||||
builtInModNames: string[];
|
||||
}
|
||||
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [modInfos] = useMods();
|
||||
|
||||
// State for the modal and dependencies
|
||||
const [open, setOpen] = useState(false);
|
||||
const [missing, setMissing] = useState<string[]>([]);
|
||||
const [builtins, setBuiltins] = useState<string[]>([]);
|
||||
const [incompatible, setIncompatible] = useState<string[]>([]);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
|
||||
// State for all mods fetched from backend
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [allMods, setAllMods] = useState<lib.ModInfo[]>([]);
|
||||
const [mods, setMods] = useState<lib.ModInfo[]>([]);
|
||||
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
|
||||
setFactorioVersion(null);
|
||||
}
|
||||
}, [props.modPack.factorioVersion]);
|
||||
|
||||
// Fetch all mods from backend when modal opens or version changes
|
||||
useEffect(() => {
|
||||
if (!open || !factorioVersion) {
|
||||
// Clear if closed or no version
|
||||
setMods([]);
|
||||
setAllMods([]);
|
||||
setMissing([]);
|
||||
setBuiltins([]);
|
||||
setIncompatible([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let canceled = false;
|
||||
|
||||
control.send(
|
||||
lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates)
|
||||
).then(response => {
|
||||
if (canceled) { return; }
|
||||
setMods(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`)));
|
||||
setIncompatible(response.incompatible);
|
||||
setAllMods(response.dependencies);
|
||||
setMissing(response.missing.filter(name => !props.builtInModNames.includes(name)));
|
||||
setBuiltins(response.missing.filter(name => props.builtInModNames.includes(name)));
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
if (canceled) { return; }
|
||||
notifyErrorHandler("Error fetching mods dependencies")(err);
|
||||
setError(err);
|
||||
setMods([]);
|
||||
setAllMods([]);
|
||||
setMissing([]);
|
||||
setBuiltins([]);
|
||||
setIncompatible([]);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
// Re-fetch when modal opens or factorio version changes
|
||||
}, [open, factorioVersion, control]);
|
||||
|
||||
// Function to handle download from Mod Portal to Controller
|
||||
function handleControllerDownload() {
|
||||
if (!factorioVersion || !mods) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable any required builtins
|
||||
for (const name of builtins) {
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: name,
|
||||
value: { ...props.modPack.mods.get(name)!, enabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Disable any incompatible mods present
|
||||
for (const name of incompatible) {
|
||||
const modRecord = props.modPack.mods.get(name);
|
||||
if (modRecord) {
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: name,
|
||||
value: { ...modRecord, enabled: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add all dependencies to the mod pack
|
||||
for (const mod of allMods) {
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: mod.name,
|
||||
value: {
|
||||
name: mod.name,
|
||||
version: mod.version,
|
||||
sha1: mod.sha1,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Download the missing dependencies
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
mods.map(mod => ({
|
||||
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
|
||||
})),
|
||||
factorioVersion,
|
||||
)
|
||||
).then(() => {
|
||||
notification.success({
|
||||
message: "Download complete",
|
||||
description: `${mods.length} mods have been downloaded to the controller.`,
|
||||
});
|
||||
}).catch(
|
||||
notifyErrorHandler("Error starting mod download")
|
||||
);
|
||||
};
|
||||
|
||||
return <>
|
||||
{props.checkForUpdates && <Button
|
||||
disabled={props.disabled}
|
||||
icon={<CloudSyncOutlined />}
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Check for Updates</Button> || <Button
|
||||
disabled={props.disabled}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Download Dependencies</Button>}
|
||||
<Modal
|
||||
title={props.checkForUpdates ? "Download Updates" : "Download Dependencies"}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
width={1000}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => { setOpen(false); }}>
|
||||
Close
|
||||
</Button>,
|
||||
<Button
|
||||
key="start"
|
||||
type="primary"
|
||||
disabled={loading || mods.length === 0}
|
||||
onClick={() => { setOpen(false); handleControllerDownload(); }}
|
||||
>Start Download</Button>,
|
||||
]}
|
||||
>
|
||||
{/* Display error message if fetching failed */}
|
||||
{error && <Alert
|
||||
message="Error Fetching Mods"
|
||||
description={error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>}
|
||||
|
||||
{/* Display warning message if dependencies could not be found */}
|
||||
{!loading && missing.length > 0 && <Alert
|
||||
message="Failed to Find Dependencies:"
|
||||
description={<ul>
|
||||
{missing.map(name => <li>{name}</li>)}
|
||||
</ul>}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>}
|
||||
|
||||
{/* Display warning message if dependencies are incompatible */}
|
||||
{!loading && incompatible.length > 0 && <Alert
|
||||
message="Incompatible Dependencies Found:"
|
||||
description={<ul>
|
||||
{incompatible.map(name => <li>{name}</li>)}
|
||||
</ul>}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>}
|
||||
|
||||
<Table
|
||||
dataSource={mods}
|
||||
rowKey={record => record.name}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100"],
|
||||
showTotal: (total: number) => (
|
||||
props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies`
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: "Title",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: "Author",
|
||||
dataIndex: "author",
|
||||
key: "author",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: "Selected Version",
|
||||
dataIndex: "version",
|
||||
key: "version",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
type ModsTableProps = {
|
||||
modPack: lib.ModPack;
|
||||
changes: ModChange[];
|
||||
onChange: (change: ModChange) => void;
|
||||
onRevert: (change: ModChange) => void;
|
||||
builtInMods: string[];
|
||||
builtInModNames: string[];
|
||||
};
|
||||
function ModsTable(props: ModsTableProps) {
|
||||
const [showAddMods, setShowAddMods] = useState(false);
|
||||
const [modsMap] = useMods();
|
||||
const account = useAccount();
|
||||
const [modInfos] = useMods();
|
||||
|
||||
const deletedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
|
||||
const changedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
|
||||
const [showAddMods, setShowAddMods] = useState(false);
|
||||
|
||||
const deletedMods: Map<string, lib.ModRecord> = new Map();
|
||||
const changedMods: Map<string, lib.ModRecord> = new Map();
|
||||
for (let change of props.changes) {
|
||||
if (change.name) {
|
||||
if (props.modPack.mods.has(change.name)) {
|
||||
@@ -265,34 +520,119 @@ function ModsTable(props: ModsTableProps) {
|
||||
}
|
||||
|
||||
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
|
||||
(mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => {
|
||||
if (props.builtInMods.includes(mod.name)) {
|
||||
(mod: lib.ModRecord): lib.ModRecord => {
|
||||
if (props.builtInModNames.includes(mod.name)) {
|
||||
return {
|
||||
...mod,
|
||||
enabled: (mod as any).enabled ?? false,
|
||||
enabled: mod.enabled ?? false,
|
||||
};
|
||||
}
|
||||
const candidate = modsMap.get(`${mod.name}_${mod.version}`);
|
||||
if (!candidate) {
|
||||
const modInfo = modInfos.get(`${mod.name}_${mod.version}`);
|
||||
if (!modInfo) {
|
||||
return {
|
||||
...mod,
|
||||
enabled: (mod as any).enabled ?? false,
|
||||
enabled: mod.enabled ?? false,
|
||||
error: "missing",
|
||||
};
|
||||
} else if (mod.sha1 && candidate.sha1 !== mod.sha1) {
|
||||
} else if (mod.sha1 && modInfo.sha1 !== mod.sha1) {
|
||||
return {
|
||||
...mod,
|
||||
enabled: (mod as any).enabled ?? false,
|
||||
enabled: mod.enabled ?? false,
|
||||
error: "bad_checksum",
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
return {
|
||||
...mod,
|
||||
enabled: mod.enabled ?? false,
|
||||
info: modInfo,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function actions(mod: lib.ModRecord|lib.ModInfo) {
|
||||
for (const mod of mods) {
|
||||
if (mod.enabled && mod.info) {
|
||||
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
|
||||
try {
|
||||
const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion);
|
||||
const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion);
|
||||
if (packFactorioVersion !== modFactorioVersion) {
|
||||
mod.warning = "wrong_factorio_version";
|
||||
}
|
||||
} catch {
|
||||
// Hit if the mod or mod pack version is not a valid api version
|
||||
mod.warning = "wrong_factorio_version";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fixDependencyIssues(mod: lib.ModRecord) {
|
||||
if (!mod.info) {
|
||||
throw new Error("Mod record has no info"); // Should be unreachable
|
||||
}
|
||||
if (mod.info.factorioVersion === "0.12") {
|
||||
throw new Error("Factorio version 0.12 not supported by mod portal");
|
||||
}
|
||||
|
||||
let hasFailure = false;
|
||||
const enabledMods = mods.filter(m => m.enabled);
|
||||
const modInfoArray = [...modInfos.values()];
|
||||
for (const dependency of mod.info.dependencies) {
|
||||
const reason = dependency.checkUnsatisfiedReason(enabledMods);
|
||||
if (reason === undefined) {
|
||||
continue; // No issues for this dependency
|
||||
}
|
||||
|
||||
if (reason === "incompatible") {
|
||||
// Disable the incompatible mod
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: dependency.name,
|
||||
value: { ...props.modPack.mods.get(dependency.name)!, enabled: false },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (props.builtInModNames.includes(dependency.name)) {
|
||||
// If builtin mod then enable it
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: dependency.name,
|
||||
value: { ...props.modPack.mods.get(dependency.name)!, enabled: true },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a suitable version to add
|
||||
const candidate = modInfoArray
|
||||
.filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion
|
||||
&& (!dependency.version || dependency.version.testVersion(info.version))
|
||||
))
|
||||
.reduce<lib.ModInfo | undefined>((max, cur) => (
|
||||
max && max.integerVersion > cur.integerVersion ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (candidate) {
|
||||
props.onChange({
|
||||
type: "mods.set",
|
||||
name: candidate.name,
|
||||
value: {
|
||||
name: candidate.name,
|
||||
version: candidate.version,
|
||||
sha1: candidate.sha1,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
hasFailure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasFailure;
|
||||
}
|
||||
|
||||
function actions(mod: lib.ModRecord) {
|
||||
return <Space>
|
||||
{!deletedMods.has(mod.name) && !props.builtInMods.includes(mod.name) && <Typography.Link
|
||||
{!deletedMods.has(mod.name) && !props.builtInModNames.includes(mod.name) && <Typography.Link
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
props.onChange({
|
||||
@@ -311,6 +651,13 @@ function ModsTable(props: ModsTableProps) {
|
||||
});
|
||||
}}
|
||||
>revert</Typography.Link>}
|
||||
{(mod.warning && mod.warning !== "wrong_factorio_version") && <Typography.Link
|
||||
onClick={async () => {
|
||||
if (await fixDependencyIssues(mod)) {
|
||||
notify("Failed to automatically fix all issues", "warning");
|
||||
}
|
||||
}}
|
||||
>fix issues</Typography.Link>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
@@ -318,6 +665,38 @@ function ModsTable(props: ModsTableProps) {
|
||||
<SectionHeader
|
||||
title="Mods"
|
||||
extra={<Space>
|
||||
<Button
|
||||
icon={<ToolOutlined />}
|
||||
disabled={!mods.some(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")}
|
||||
onClick={async () => {
|
||||
const hasFailure = await Promise.all(mods
|
||||
.filter(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")
|
||||
.map(m => fixDependencyIssues(m))
|
||||
);
|
||||
if (hasFailure.some(v => v)) {
|
||||
notify("Failed to automatically fix all issues", "warning");
|
||||
}
|
||||
}}
|
||||
>Fix Issues</Button>
|
||||
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
|
||||
&& <DownloadDependenciesButton
|
||||
mods={mods}
|
||||
modPack={props.modPack}
|
||||
onChange={props.onChange}
|
||||
builtInModNames={props.builtInModNames}
|
||||
disabled={!mods.some(
|
||||
m => m.enabled
|
||||
&& (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning))
|
||||
)}
|
||||
/>}
|
||||
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
|
||||
&& <DownloadDependenciesButton
|
||||
mods={mods}
|
||||
modPack={props.modPack}
|
||||
onChange={props.onChange}
|
||||
builtInModNames={props.builtInModNames}
|
||||
checkForUpdates
|
||||
/>}
|
||||
<Button icon={<PlusOutlined />} onClick={() => setShowAddMods(true)}>Add</Button>
|
||||
</Space>}
|
||||
/>
|
||||
@@ -361,14 +740,26 @@ function ModsTable(props: ModsTableProps) {
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, mod: any) => <>
|
||||
render: (_, mod: lib.ModRecord) => <>
|
||||
{mod.error === "missing" && <Tooltip title="Mod is missing from storage.">
|
||||
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum missmatch.">
|
||||
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum mismatch.">
|
||||
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.title || mod.name}
|
||||
{mod.warning === "incompatible" && <Tooltip title="Mod is incompatible with another.">
|
||||
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.warning === "missing_dependency" && <Tooltip title="Mod is missing a dependency.">
|
||||
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.warning === "wrong_version" && <Tooltip title="Mod has wrong dependency version added.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.warning === "wrong_factorio_version" && <Tooltip title="Mod has wrong factorio version.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.info?.title || mod.name}
|
||||
</>,
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
@@ -386,7 +777,7 @@ function ModsTable(props: ModsTableProps) {
|
||||
},
|
||||
]}
|
||||
expandable={{
|
||||
expandedRowRender: mod => <ModDetails mod={mod} actions={actions} />,
|
||||
expandedRowRender: mod => <ModDetails mod={mod} mods={mods.filter(m => m.enabled)} actions={actions} />,
|
||||
expandedRowClassName: () => "no-expanded-padding",
|
||||
}}
|
||||
dataSource={mods}
|
||||
@@ -908,9 +1299,7 @@ export default function ModPackViewPage() {
|
||||
changes={changes}
|
||||
onChange={pushChange}
|
||||
onRevert={revertChange}
|
||||
builtInMods={
|
||||
lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion).map(builtinMod => builtinMod.name)
|
||||
}
|
||||
builtInModNames={lib.ModPack.getBuiltinModNames(modifiedModPack.factorioVersion)}
|
||||
/>
|
||||
<SettingsTable
|
||||
modPack={modifiedModPack}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {
|
||||
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -25,9 +24,6 @@ import UploadButton from "./UploadButton";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
// Define the allowed Factorio versions based on the lib schema
|
||||
type FactorioVersion = Static<(typeof lib.ModPortalGetAllRequest)["allowedVersions"][number]>;
|
||||
|
||||
// Type alias for the mod structure derived from the library response
|
||||
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
||||
|
||||
@@ -145,7 +141,7 @@ function SearchModsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [factorioVersion, setFactorioVersion] = useState<FactorioVersion>("2.0");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
|
||||
|
||||
// State for all mods fetched from backend
|
||||
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
|
||||
@@ -162,22 +158,21 @@ function SearchModsButton() {
|
||||
const handleControllerDownload = (
|
||||
modName: string,
|
||||
modTitle: string | undefined,
|
||||
modVersion: string | undefined,
|
||||
portalFactorioVersion: FactorioVersion,
|
||||
modVersion: lib.FullVersion | undefined,
|
||||
portalFactorioVersion: lib.ApiVersion,
|
||||
) => {
|
||||
if (modVersion) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
modName,
|
||||
modVersion,
|
||||
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
|
||||
portalFactorioVersion
|
||||
)
|
||||
).then(() => {
|
||||
notification.success({
|
||||
message: "Download started",
|
||||
message: "Download complete",
|
||||
description: `${modTitle || modName} v${
|
||||
modVersion
|
||||
} is being downloaded to the controller.`,
|
||||
} has been downloaded to the controller.`,
|
||||
});
|
||||
}).catch(
|
||||
notifyErrorHandler("Error starting mod download")
|
||||
@@ -270,7 +265,7 @@ function SearchModsButton() {
|
||||
// Update search text and factorio version from form
|
||||
const handleSearch = (changedValues: any, allValues: any) => {
|
||||
const nameValue = allValues.name;
|
||||
const versionValue = allValues.factorioVersion as FactorioVersion | undefined;
|
||||
const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined;
|
||||
|
||||
// Only trigger state updates if values actually changed
|
||||
if (nameValue !== searchText) {
|
||||
@@ -351,12 +346,9 @@ function SearchModsButton() {
|
||||
label="Factorio Version"
|
||||
>
|
||||
<Select>
|
||||
{lib.ModPortalGetAllRequest.allowedVersions.map((literalSchema: any) => (
|
||||
<Select.Option
|
||||
key={literalSchema.const}
|
||||
value={literalSchema.const}
|
||||
>
|
||||
{literalSchema.const}
|
||||
{lib.ApiVersions.map(version => (
|
||||
<Select.Option key={version} value={version}>
|
||||
{version}
|
||||
</Select.Option>
|
||||
)).reverse()}
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
import { useUsers } from "../model/user";
|
||||
import UsersTable from "./UsersTable";
|
||||
|
||||
type OnlinePlayersListProps = {
|
||||
instanceId: number;
|
||||
};
|
||||
|
||||
export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) {
|
||||
const [users] = useUsers();
|
||||
|
||||
// Filter users to only show those online on this specific instance
|
||||
const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId));
|
||||
|
||||
if (onlineUsers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Online Players ({onlineUsers.length})</h4>
|
||||
<UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRole } from "../model/roles";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageHeader from "./PageHeader";
|
||||
@@ -12,30 +13,6 @@ import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
function useRole(id: number): [Readonly<lib.Role> | undefined, boolean, () => void] {
|
||||
let control = useContext(ControlContext);
|
||||
let [role, setRole] = useState<lib.Role>();
|
||||
let [synced, setSynced] = useState(false);
|
||||
|
||||
function updateRole() {
|
||||
// XXX optimize by requesting only the role in question
|
||||
control.send(new lib.RoleListRequest()).then(roles => {
|
||||
let match = roles.find(u => u.id === id);
|
||||
if (match) {
|
||||
setRole(match);
|
||||
}
|
||||
setSynced(true);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateRole();
|
||||
}, [id]);
|
||||
|
||||
return [role, synced, updateRole];
|
||||
}
|
||||
|
||||
export default function RoleViewPage() {
|
||||
let params = useParams();
|
||||
let roleId = Number(params.id);
|
||||
@@ -44,7 +21,7 @@ export default function RoleViewPage() {
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
const [role, synced, updateRole] = useRole(roleId);
|
||||
const [role, synced] = useRole(roleId);
|
||||
let [edited, setEdited] = useState(false);
|
||||
|
||||
|
||||
@@ -63,6 +40,7 @@ export default function RoleViewPage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
// TODO: Update displayed perms when role perms change and have not been edited locally
|
||||
let initialValues = {
|
||||
name: role["name"],
|
||||
description: role["description"],
|
||||
@@ -88,7 +66,6 @@ export default function RoleViewPage() {
|
||||
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
|
||||
).then(() => {
|
||||
setEdited(false);
|
||||
updateRole();
|
||||
}).catch(notifyErrorHandler("Error applying changes"));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, Table } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
@@ -60,16 +61,8 @@ function CreateRoleButton() {
|
||||
|
||||
export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
|
||||
let [roles, setRoles] = useState<lib.Role[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
control.send(new lib.RoleListRequest()).then(newRoles => {
|
||||
setRoles(newRoles);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, []);
|
||||
const [roles] = useRoles();
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
<PageHeader
|
||||
@@ -88,7 +81,7 @@ export default function RolesPage() {
|
||||
dataIndex: "description",
|
||||
},
|
||||
]}
|
||||
dataSource={roles}
|
||||
dataSource={[...roles.values()]}
|
||||
pagination={false}
|
||||
rowKey={role => role.id}
|
||||
onRow={(role, rowIndex) => ({
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { ButtonProps } from "antd/es/button";
|
||||
|
||||
type StartStopInstanceButtonProps = {
|
||||
instance: lib.InstanceDetails;
|
||||
onFinish?: () => void;
|
||||
buttonProps?: ButtonProps;
|
||||
};
|
||||
export default function StartStopInstanceButton(props: StartStopInstanceButtonProps) {
|
||||
let control = useContext(ControlContext);
|
||||
let [switching, setSwitching] = useState<boolean>(false);
|
||||
|
||||
function onClick(event: React.MouseEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
setSwitching(true);
|
||||
let action;
|
||||
if (props.instance.status === "stopped") {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStartRequest(undefined),
|
||||
).catch(
|
||||
notifyErrorHandler("Error starting instance")
|
||||
);
|
||||
|
||||
} else if (["starting", "running"].includes(props.instance.status)) {
|
||||
action = control.sendTo(
|
||||
{ instanceId: props.instance.id },
|
||||
new lib.InstanceStopRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error stopping instance")
|
||||
);
|
||||
|
||||
} else {
|
||||
setSwitching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
action.finally(() => {
|
||||
setSwitching(false);
|
||||
if (props.onFinish) {
|
||||
props.onFinish();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return <Button
|
||||
{...(props.buttonProps || {})}
|
||||
loading={switching}
|
||||
type="primary"
|
||||
disabled={!["starting", "running", "stopped"].includes(props.instance.status)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.instance.status === "stopped" ? "Start" : "Stop"}
|
||||
</Button>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useRoles } from "../model/roles";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useInstances } from "../model/instance";
|
||||
import ControlContext from "./ControlContext";
|
||||
@@ -35,21 +36,13 @@ export default function UserViewPage() {
|
||||
let control = useContext(ControlContext);
|
||||
let [instances] = useInstances();
|
||||
const [user, synced] = useUser(userName);
|
||||
let [roles, setRoles] = useState<Map<number, lib.Role>>(new Map());
|
||||
let [roles] = useRoles();
|
||||
let [form] = Form.useForm();
|
||||
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
|
||||
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
|
||||
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
|
||||
let [rolesError, setRolesError] = useState<string|undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
control.send(new lib.RoleListRequest()).then(newRoles => {
|
||||
setRoles(new Map(newRoles.map(role => [role.id, role])));
|
||||
}).catch(() => {
|
||||
setRoles(new Map());
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
account.hasPermission("core.user.set_banned")
|
||||
@@ -76,6 +69,7 @@ export default function UserViewPage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
// TODO: Update displayed roles when user roles change and have not been edited locally
|
||||
let roleSelector = <Form.Item
|
||||
noStyle
|
||||
name="roles"
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user