Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] 0e0d453159 Initial plan 2026-02-02 21:21:11 +00:00
Cooldude2606 a4e4621eaa Git ignore config lock files 2026-01-30 21:22:36 +00:00
Cooldude2606 4337555ea1 Fix gitignore "factorio" rule 2026-01-30 20:54:59 +00:00
CopilotandDanielv123 9018397b09 Fix logistics data on servers without logistic researched (#809)
* Initial plan

* Add logistics technology validation to prevent load failures

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2026-01-28 15:03:47 +00:00
Danielv123 1c9aae5909 Fix crash in logging 2026-01-27 15:59:35 +01:00
Daniel 6489fa4048 Merge pull request #823 from Cooldude2606/fix/update-plugin-ts-config
Updates package jsons and tsconfigs to have catalogs and correct peer deps
2026-01-25 20:10:14 +01:00
Daniel 22850b7cd0 Merge pull request #815 from Cooldude2606/feature/database-lock
Add extra startup warnings to CLI: User count and Lock file
2026-01-25 20:08:36 +01:00
Daniel f65c8fb9a0 Merge pull request #816 from Cooldude2606/fix/download-headless
Fix headless download
2026-01-25 20:08:18 +01:00
Daniel 3232da71e2 Merge pull request #817 from Cooldude2606/fix/disable-achivement
Fix achievements not being disabled when save patching disabled
2026-01-25 20:08:05 +01:00
Daniel c9fc4f1c8f Merge pull request #821 from Cooldude2606/feature/version-selector
Add drop down version selector based on Wube version archive
2026-01-25 20:07:38 +01:00
Cooldude2606 0c56c56f74 Fixed duplicate pnpm version spec 2026-01-25 00:26:53 +00:00
Cooldude2606 2928b9b7c5 Bump node and pnpm version 2026-01-25 00:23:09 +00:00
Cooldude2606 1fc6971278 Switch to catalogs 2026-01-25 00:02:20 +00:00
Cooldude2606 2679851d66 Update plugin template 2026-01-24 22:54:59 +00:00
Cooldude2606 ab86cd2b1c Update package json and tsconfig 2026-01-24 22:47:40 +00:00
Cooldude2606 02fce63e11 Fix git ignore 2026-01-24 21:03:57 +00:00
Cooldude2606 8981a018b0 Fix lint issue 2026-01-24 18:33:28 +00:00
Cooldude2606 bbf5df6d01 Add remaining tests 2026-01-24 17:58:36 +00:00
Cooldude2606 6f68cf74a7 Add network events and switch to tree select 2026-01-24 16:27:28 +00:00
Cooldude2606 07f501a67a Add ValueCache and fetchFactorioVersions 2026-01-23 01:50:20 +00:00
Daniel 4caf06aafc Merge pull request #819 from Cooldude2606/feature/login-screen-name
Display cluster name on login screen
2026-01-22 15:45:00 +01:00
Cooldude2606 4901d76ebe Refactor version inputs 2026-01-22 00:37:34 +00:00
Cooldude2606 6b0dd4e9cf Add tests for new api route 2026-01-21 23:47:47 +00:00
Cooldude2606 a2c38ad4e8 Add cluster name to login form 2026-01-21 23:42:53 +00:00
Cooldude2606 438ccbdc7f Fix achievements not being disabled 2026-01-14 21:24:03 +00:00
Cooldude2606 d6c5430e0c Fix headless download 2026-01-11 17:05:21 +00:00
Cooldude2606 d48b731ea4 Add lockfile bypass 2026-01-08 22:48:00 +00:00
Cooldude2606 bccb6527a7 Add lock file for configs 2026-01-07 01:20:40 +00:00
Cooldude2606 00cf0f59ec Refuse to start controller with no users 2026-01-07 00:21:56 +00:00
Double 3a823bee01 Update link to message passing protocol specification (#813) 2026-01-01 19:23:54 +00:00
Cooldude2606 98650b9bfb 2.0.0-alpha.22b (plugins)
Due to the bump of lib version, all plugins must also be bumped to 22b
2025-11-30 22:07:37 +00:00
Daniel a781af5b6a Merge pull request #811 from clusterio/copilot/fix-add-mods-button-visibility
Fix button overflow on narrow screens across web interface
2025-11-22 23:21:55 +01:00
copilot-swe-agent[bot]andDanielv123 f202a0975a Add wrap to more Space components and fix table scroll on narrow screens
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 10:55:42 +00:00
copilot-swe-agent[bot]andDanielv123 ea4280d184 Add wrap prop to Space components in SectionHeader for mobile responsiveness
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 07:52:22 +00:00
copilot-swe-agent[bot] c519ee1389 Initial plan 2025-11-20 07:45:31 +00:00
CopilotandDanielv123 c61b9dbe60 Remove debug log statement from serialize.lua (#808)
* Initial plan

* Remove debug log statement from serialize.lua

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-11 12:21:07 +01:00
Cooldude2606 77f4aa6296 2.0.0-alpha.22b 2025-11-07 21:55:35 +00:00
Cooldude2606 86a9084d56 Fix package updater 2025-11-07 21:48:09 +00:00
Cooldude2606 43def44136 Fix package path for restart required 2025-11-07 21:47:48 +00:00
Cooldude2606 8c1be7210e Skip git checks on npm publish 2025-11-07 20:08:22 +00:00
Cooldude2606 24e290746d 2.0.0-alpha.22 2025-11-07 19:48:21 +00:00
Cooldude2606 d52931a326 Better handling of mod dependencies (#801)
* Add ModDependency and version types

* Add warnings for mod dependencies

* Add warning for wrong factorio version

* Add fix issues button

* Add automatic dependency resolution and download

* Add abilty to check for updates

* Add extra test coverage

* One extra test

* Fix copy paste error
2025-11-07 19:22:17 +00:00
Cooldude2606 73cc019d2f Sets restart required when a plugin is updated (#794)
* Template type for load plugin config

* Add plugin update checking

* Add restart icon to instance list

* Add tests

* Log package name when failing to read package json
2025-11-01 16:11:11 +00:00
Cooldude2606 41151d47f0 Add publish workflow (#798)
* Change build-mod to belong to packages

* Add publish script and workflow

* Fix typo in variable name
2025-11-01 16:10:41 +00:00
Cooldude2606 3fbd9a995d Add better plugin index missing error (#799)
* Add better plugin load errors

* Remove check for permission

* Account for packages using different main file
2025-11-01 16:10:24 +00:00
Daniel af299bc08d Merge pull request #800 from Cooldude2606/fix/template-ignore
Fix plugin template npm and git ignore
2025-10-30 13:54:29 +01:00
Cooldude2606 732cee4837 Fix plugin template npm and git ignore 2025-09-19 23:45:31 +01:00
105 changed files with 6377 additions and 710 deletions

No files matched your search

+2 -8
View File
@@ -20,8 +20,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@@ -48,11 +46,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -61,11 +57,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
+48
View File
@@ -0,0 +1,48 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+3 -8
View File
@@ -29,18 +29,13 @@ dist/
node_modules
Factorio*
/factorio
!/test/file/factorio
!/test/lib/factorio
!/packages/lib/src/factorio
/factorio*
/instances
/mods
/database
/plugin-list.json
/config-controller.json
/config-control.json
/config-host.json
/config-*.json
/config-*.json.lock
pnpm-lock.yaml
mod_cache.json
/static/
+45
View File
@@ -31,6 +31,51 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
+1 -1
View File
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation start up the controller and at least one host separately.
After completing the installation and creating your admin user, start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
+3 -3
View File
@@ -86,8 +86,8 @@ async function removeExecutionArtifacts() {
async function main() {
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,
@@ -108,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();
}
+1 -1
View File
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
## Communicating with Clusterio
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [socket.md](socket.md) for the implementation details of it.
See [protocol.md](devs/protocol.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
+3 -2
View File
@@ -2,10 +2,11 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
@@ -27,7 +28,7 @@
]
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"workspaces": [
"packages/*",
+26 -5
View File
@@ -127,7 +127,8 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
): Promise<void> {
let subCommand = args._[1];
@@ -141,6 +142,8 @@ async function handleBootstrapCommand(
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
@@ -156,6 +159,7 @@ async function handleBootstrapCommand(
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
let user = userManager.getByName(args.name);
@@ -251,6 +255,7 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
@@ -281,6 +286,7 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -350,8 +356,14 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let controllerConfig;
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
@@ -377,10 +389,11 @@ async function initialize(): Promise<InitializeParameters> {
}
if (command === "config") {
await lib.handleConfigCommand(args, controllerConfig);
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
return {
@@ -417,6 +430,14 @@ async function startup() {
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.userManager.users.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
+29 -29
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -17,45 +17,45 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"express": "^4.21.2",
"finalhandler": "^1.3.1",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.13.3",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.14",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.29.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
"@types/compression": "^1.7.2",
"@types/busboy": "^1.5.0",
"@swc/core": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -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) {
+177 -44
View File
@@ -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,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -564,52 +566,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) {
@@ -1058,7 +1189,7 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/controller");
return await lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1079,9 +1210,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`),
));
}
@@ -1094,6 +1223,10 @@ export default class ControlConnection extends BaseConnection {
this.connector._socket!.clusterio_ignore_dump = true;
this._controller.debugEvents.on("message", this.ws_dumper);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
module.exports = ControlConnection;
+58 -15
View File
@@ -105,6 +105,8 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
@@ -135,7 +137,8 @@ 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());
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
@@ -549,19 +552,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);
@@ -571,6 +563,46 @@ export default class Controller {
}
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/controller";
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -673,6 +705,17 @@ 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 => {
@@ -1127,7 +1170,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;
}
+2 -2
View File
@@ -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) {
+8 -3
View File
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
@@ -136,6 +134,12 @@ function getPlugins(req: Request, res: Response) {
res.send(plugins);
}
function getClusterName(req: Request, res: Response) {
const controller: Controller = req.app.locals.controller;
const clusterName = controller.config.get("controller.name");
res.json({ name: clusterName });
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
@@ -664,6 +668,7 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
+9 -6
View File
@@ -754,8 +754,8 @@ async function inquirerMissingArgs(args) {
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
@@ -772,10 +772,7 @@ async function downloadLinuxServer() {
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await fetch(url.href);
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
@@ -794,8 +791,10 @@ async function downloadLinuxServer() {
throw e;
}
await fs.unlink(archivePath);
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
@@ -821,6 +820,10 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21c",
"version": "2.0.0-alpha.22b",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,11 +13,11 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.3.0",
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+2 -2
View File
@@ -94,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}`));
@@ -15,6 +15,18 @@
"node": "__node_version__"
},
"peerDependencies": {
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"devDependencies": {
@@ -36,7 +48,18 @@
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
//%endif
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
+9 -2
View File
@@ -189,6 +189,7 @@ export async function initialize(
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
@@ -234,8 +235,14 @@ export async function initialize(
.parse(argv) as CtlArguments
;
let controlConfig;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
@@ -257,7 +264,7 @@ export async function initialize(
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig);
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
+10 -10
View File
@@ -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",
@@ -17,25 +17,25 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"phin": "catalog:",
"as-table": "^1.0.55",
"fs-extra": "^11.3.0",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+15 -6
View File
@@ -1362,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)) {
@@ -1415,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;
}
@@ -1496,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;
}
@@ -1551,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) {
@@ -1638,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,
@@ -1717,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")!);
@@ -1757,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));
},
}));
+12 -2
View File
@@ -113,6 +113,7 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
@@ -167,8 +168,14 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let hostConfig;
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
@@ -186,10 +193,13 @@ async function startHost() {
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig);
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
@@ -70,7 +70,6 @@ function serialize.serialize_equipment_grid(grid)
if equipment.to_be_removed then
entry.t = equipment.to_be_removed
end
log(entry)
table.insert(serialized, entry)
end
end
+14 -13
View File
@@ -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.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -17,33 +18,33 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.3.0",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"phin": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+46 -16
View File
@@ -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,48 @@ 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 packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async handleHostMetricsRequest() {
@@ -1066,7 +1096,7 @@ export default class Host extends lib.Link {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/host");
return await lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1087,7 +1117,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`),
));
}
+12 -5
View File
@@ -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,
@@ -553,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,
),
);
}
@@ -651,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;
}
@@ -960,10 +962,15 @@ end`.replace(/\r?\n/g, " ");
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
if (this.config.get("factorio.enable_script_commands")) {
await this.server.disableAchievements();
await this.updateInstanceData();
}
if (this.config.get("factorio.enable_save_patching")) {
this._watchPlayerPromote();
if (this.config.get("factorio.enable_script_commands")) {
await this.updateInstanceData();
}
} else {
this._watchServerLogActions();
}
+21 -11
View File
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
* @returns Array with path to data dir and version found.
* @internal
*/
async function findVersion(factorioDir: string, targetVersion: string) {
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
@@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: string) {
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
if (simpleVersion === targetVersion || targetVersion === "latest") {
return [path.join(factorioDir, "data"), simpleVersion];
if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") {
if (lib.isFullVersion(simpleVersion)) {
return [path.join(factorioDir, "data"), simpleVersion];
}
}
throw new Error(
@@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: string) {
);
}
let versions = new Map();
let versions = new Map<lib.FullVersion, string>();
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version === null) {
if (version === null || !lib.isFullVersion(version)) {
continue;
}
@@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: string) {
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
}
if (lib.isPartialVersion(targetVersion)) {
const sorted = [...versions.keys()].sort(versionOrder);
const latest = sorted.find(version => version.startsWith(targetVersion));
if (latest) {
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
}
if (targetVersion === "latest") {
let latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest), "data"), latest];
const latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
@@ -423,7 +433,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: string,
version?: lib.TargetVersion,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -523,7 +533,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_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
@@ -532,7 +542,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_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;
@@ -866,7 +876,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
* "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;
}
+1
View File
@@ -15,3 +15,4 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
+14 -2
View File
@@ -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
+3
View File
@@ -9,6 +9,7 @@ export * from "./src/config";
export * from "./src/data";
export * from "./src/database";
export * from "./src/errors";
export * from "./src/external";
export * from "./src/factorio";
export * from "./src/file_ops";
export * from "./src/hash";
@@ -31,7 +32,9 @@ export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export * from "./src/LockFile";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -18,30 +18,30 @@
"node": ">=12"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"ajv": "^8.17.1",
"chalk": "^4.1.2",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"chalk": "catalog:",
"ws": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.3.0",
"jszip": "^3.10.1",
"klaw": "^4.1.0",
"set-blocking": "^2.0.0",
"triple-beam": "^1.4.1",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"ajv": "^8.17.1",
"klaw": "^4.1.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/set-blocking": "catalog:",
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+163
View File
@@ -0,0 +1,163 @@
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
* Error thrown when the lock file already exists.
*/
export class LockFileExistsError extends Error {
constructor(
readonly filePath: string
) {
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
this.name = "LockFileExistsError";
}
}
/**
* Error thrown when the acquire or release called during an invalid state.
*/
export class LockFileBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockFileBusyError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
private handle: FileHandle | null = null;
private state: LockState = "idle";
constructor(
private readonly filePath: string
) {}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
}
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
this.state = "idle";
if (err?.code === "EEXIST") {
throw new LockFileExistsError(this.filePath);
}
throw err;
}
}
/**
* Release the lock file.
* Throws LockFileBusyError if not in acquired state.
*/
async release(): Promise<void> {
if (this.state !== "acquired") {
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
}
this.state = "releasing";
this.detachExitHandlers();
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
this.handle = null;
this.state = "idle";
}
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
return false;
}
}
/**
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
}
}
/**
* Attach process handlers, called when transitioning to "acquired"
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
process.off("SIGHUP", this.handleExit);
process.off("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
} catch (err) {
// Ignored: file may have already been removed by another process
}
};
}
+103 -65
View File
@@ -3,10 +3,10 @@ import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo, ModPack } from "./data";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } 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 */
@@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
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 events.EventEmitter<ModStoreEvents> {
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 events.EventEmitter<ModStoreEvents> {
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 events.EventEmitter<ModStoreEvents> {
}
}
/**
* Downloads all provided mods, does not perform chunking or filtering of builtins
*
* @param mods - An array of mods to download
* @param username - Factorio username used for auth to download mods
* @param token - Factorio token used for auth to download mods
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
private async downloadModsChunk(
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 events.EventEmitter<ModStoreEvents> {
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
/**
* Fetch the full release list for a mod including dependencies.
*
* @param modName - Name of the mod to fetch the releases of
* @returns Mod portal response
*/
static async fetchModReleases(modName: string) {
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
return await response.json() as ModsInfoResponse["results"][number];
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Represents an async function which can fetch a value for a ValueCache
*/
export interface ValueCacheFetch<T> {
(): Promise<T>;
}
/**
* In-memory single value cache
*
* Stores the last time a value was fetched and measures staleness
* Concurrent calls will share the same in-flight fetch
*/
export default class ValueCache<T> {
private ongoingFetch: Promise<T> | null = null;
private timestamp: number | null = null;
private value: T | null = null; // Only valid when timestamp is not null
/**
* Create a new in memory single value cache
*
* @param fetch A function that fetches and returns a fresh value
*/
constructor(
private readonly fetch: ValueCacheFetch<T>,
) {}
/**
* Get the cached value, will fetch a fresh value if stale or missing
*
* @param maxAgeMs Maximum allowed cache age in milliseconds.
* Defaults to 0, meaning it will always fetch a new value.
* @throws TypeError if maxAgeMs is invalid
* @throws Propagates errors from underlying fetch method
*/
get(maxAgeMs: number = 0): Promise<T> {
if (maxAgeMs < 0) {
throw new TypeError("maxAgeMs must be a non-negative number");
}
const now = Date.now();
if (this.timestamp && now - this.timestamp < maxAgeMs) {
return Promise.resolve(this.value as T);
}
if (this.ongoingFetch) {
return this.ongoingFetch;
}
this.ongoingFetch = this.fetch()
.then(value => {
this.timestamp = Date.now();
this.value = value;
return value;
})
.finally(() => {
this.ongoingFetch = null;
});
return this.ongoingFetch;
}
}
+4
View File
@@ -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;
}
/**
@@ -399,6 +401,7 @@ export interface InstanceConfigFields {
"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;
@@ -476,6 +479,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "string",
initialValue: "latest",
inputComponent: "target_version",
},
"factorio.executable_path": {
description:
+149 -17
View File
@@ -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,
+28 -17
View File
@@ -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,
@@ -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);
}
}
+1 -1
View File
@@ -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";
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
import { ExternalFactorioVersionSchema } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -391,3 +392,25 @@ export class DebugWsMessageEvent {
return new this(json.direction, json.content);
}
}
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" as const;
constructor(
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
) {}
static jsonSchema = Type.Object({
"maxAgeMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
+8 -7
View File
@@ -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,7 +43,7 @@ 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,
@@ -57,7 +58,7 @@ 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()),
});
@@ -744,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({
@@ -754,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>) {
@@ -852,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({
@@ -861,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>) {
+110 -30
View File
@@ -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);
}
};
}
+224 -16
View File
@@ -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");
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data";
const ARCHIVE_URL = "https://factorio.com/download/archive";
/**
* Represents the parsed result of a factorio version
*/
export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
/**
* Fetch all factorio versions that support headless
*
* @returns A list of all factorio versions and their download URL
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res;
try {
res = await fetch(ARCHIVE_URL);
} catch (err) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`);
}
const html = await res.text();
return parseFactorioVersions(html);
}
/**
* Parse all versions from the factorio archive webpage
*
* @param html The HTML content of the archive page
* @returns A list of all factorio versions and their download URL
*/
function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
const versions = new Map<string, ExternalFactorioVersion>();
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
let match: RegExpExecArray | null;
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while ((match = anchorRegex.exec(html)) !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) {
versions.set(version, {
version: version,
stable: kind === "stable",
headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`,
});
}
}
return [...versions.values()];
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Library for interfacing with external apis
* @module lib/external
*/
export * from "./FactorioVersions";
+2
View File
@@ -35,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.SystemInfoUpdateEvent,
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
subscriptions.SubscriptionRequest,
@@ -108,6 +109,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
mod.ModDependencyResolveRequest,
role.PermissionListRequest,
role.RoleListRequest,
+6
View File
@@ -568,3 +568,9 @@ definePermission({
title: "Dump WebSocket",
description: "Dump all WebSocket communicatation from the controller.",
});
definePermission({
name: "core.external.get_factorio_versions",
title: "Get factorio versions",
description: "Get list of all factorio versions from Wube",
});
+8 -2
View File
@@ -4,6 +4,7 @@
* @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;
}
+3
View File
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
if (!name || value === undefined) {
continue;
}
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
+1 -1
View File
@@ -6,7 +6,7 @@ import { RequestError } from "./errors";
import { PluginNodeEnvInfo } from "./plugin";
const execAsync = util.promisify(exec);
async function isDev() {
function isDev() {
// dev: <devRoot>/packages/lib/dist/src/rce_ops.js
// prod: <prodRoot>/node_modules/@clusterio/lib/dist/src/rce_ops.js
return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
+7 -1
View File
@@ -8,6 +8,7 @@ import * as libConfig from "./config";
import * as libFileOps from "./file_ops";
import { logger } from "./logging";
import * as libHelpers from "./helpers";
import { LockFile } from "./LockFile";
function print(...content: any[]) {
@@ -130,11 +131,12 @@ export function configCommand(yargs: any) {
*
* @param args - yargs args object.
* @param instance - Config instance.
* @param configPath - Path to configuration file.
* @param lockFile - Lockfile required if attempting to write
*/
export async function handleConfigCommand(
args: Record<string, unknown>,
instance: libConfig.Config<any>,
lockFile: LockFile,
) {
let command = (args._ as string[])[1];
@@ -155,6 +157,8 @@ export async function handleConfigCommand(
}
} else if (command === "set") {
await lockFile.acquire();
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
@@ -172,5 +176,7 @@ export async function handleConfigCommand(
throw err;
}
}
await lockFile.release();
}
}
+2
View File
@@ -6,6 +6,7 @@
"./src/api.ts",
"./src/config/*.ts",
"./src/data/*.ts",
"./src/external/*.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/factorio/exchange_string.ts",
@@ -25,6 +26,7 @@
"./src/ExponentialBackoff.ts",
"./src/RateLimiter.ts",
"./src/TypedEventEmitter.ts",
"./src/ValueCache.ts",
],
"compilerOptions": {
"rootDir": ".",
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
+4
View File
@@ -9,6 +9,8 @@ import App from "./components/App";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputRole from "./components/InputRole";
import InputModPack from "./components/InputModPack";
import InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -109,6 +111,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
"role": InputRole,
};
@@ -34,9 +34,10 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.send(
@@ -37,9 +37,10 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.sendTo(
@@ -105,7 +106,7 @@ export default function HostViewPage() {
</PageLayout>;
}
let hostButtons = <Space>
let hostButtons = <Space wrap>
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
{
account.hasPermission("core.host.revoke_token")
@@ -0,0 +1,68 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
@@ -0,0 +1,71 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
@@ -1,12 +1,14 @@
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 InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
import * as lib from "@clusterio/lib";
@@ -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"],
},
@@ -141,7 +141,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
}
},
};
return <Space>
return <Space wrap>
{
account.hasAnyPermission(...InstanceControlButtonPermissions)
&& <InstanceControlButton instance={instance} />
+20 -3
View File
@@ -1,7 +1,8 @@
import React, { Fragment, useContext } from "react";
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Button, Card, Divider, Form, Image, Input, Space, Typography } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
import notify from "../util/notify";
import logo from "../images/logo.png";
import ControlContext from "./ControlContext";
@@ -36,6 +37,23 @@ function TokenAuth(props: SetTokenProps) {
export default function LoginForm(props: SetTokenProps) {
let plugins = useContext(ControlContext).plugins;
const [clusterName, setClusterName] = useState<string>("Clusterio");
useEffect(() => {
(async () => {
try {
let response = await fetch(`${webRoot}api/cluster-name`);
if (response.ok) {
let data = await response.json();
if (data.name) {
setClusterName(data.name);
}
}
} catch (err) {
notify("Failed to load cluster name");
}
})();
}, []);
let loginForms = [];
for (let plugin of plugins.values()) {
@@ -50,7 +68,7 @@ export default function LoginForm(props: SetTokenProps) {
return <Card style={{ maxWidth: "30em" }}>
<Space style={{ width: "100%" }} direction="vertical" align="center">
<h1>Clusterio</h1>
<h1>{clusterName}</h1>
<Image width={128} height={123} src={logo} />
Log in to the cluster using one of the methods below.
</Space>
@@ -60,4 +78,3 @@ export default function LoginForm(props: SetTokenProps) {
</Fragment>)}
</Card>;
}
+43 -5
View File
@@ -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&nbsp;Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
{Boolean(mod.factorioVersion)
&& <Descriptions.Item label="Factorio&nbsp;Version">{mod.factorioVersion}</Descriptions.Item>
&& <Descriptions.Item label="Factorio&nbsp;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";
@@ -26,18 +26,23 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
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 +52,7 @@ type ModChange =
type: "mods.set" | "mods.delete",
name: string,
scope?: never,
value: lib.ModRecord | lib.ModInfo,
value: lib.ModRecord,
}
;
@@ -71,28 +76,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 +254,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 +521,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,13 +652,52 @@ 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>;
}
return <>
<SectionHeader
title="Mods"
extra={<Space>
extra={<Space wrap>
<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 +741,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 +778,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}
@@ -897,7 +1289,9 @@ export default function ModPackViewPage() {
message: "Must be an a.b or a.b.c version number.",
}]}
>
<Input
<InputPartialVersion
value={form.getFieldValue("factorioVersion")}
onChange={(value: any) => form.setFieldValue("factorioVersion", value)}
className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""}
/>
</Form.Item>
@@ -908,9 +1302,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}
+17 -21
View File
@@ -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";
@@ -22,12 +21,10 @@ import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
import InputPartialVersion from "./InputPartialVersion";
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];
@@ -132,7 +129,10 @@ function CreateModPackButton() {
message: "Must be an a.b or a.b.c version number.",
}]}
>
<Input />
<InputPartialVersion
value={form.getFieldValue("factorioVersion")}
onChange={(value) => form.setFieldValue("factorioVersion", value)}
/>
</Form.Item>
</Form>
</Modal>
@@ -145,7 +145,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 +162,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 +269,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 +350,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>
@@ -569,7 +565,7 @@ export default function ModsPage() {
<PageHeader title="Mods" />
<SectionHeader
title="Mod Packs"
extra={<Space>
extra={<Space wrap>
{account.hasPermission("core.mod_pack.create") && <ImportModPackButton />}
{account.hasPermission("core.mod_pack.create") && <CreateModPackButton />}
</Space>}
@@ -602,7 +598,7 @@ export default function ModsPage() {
},
})}
/>
<SectionHeader title="Stored Mods" extra={<Space>
<SectionHeader title="Stored Mods" extra={<Space wrap>
<SearchModsButton />
{uploadButton}
</Space>} />
+1 -1
View File
@@ -187,7 +187,7 @@ export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: bo
{
formAction !== undefined
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
&& <Form.Item label="Restart on completion" name="restart">
&& <Form.Item label="Restart on completion" name="restart" valuePropName="checked">
<Checkbox disabled={props.canRestart === false}/>
</Form.Item>
}
@@ -1,6 +1,6 @@
import React, { useEffect, useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Form, Input, Popconfirm, Spin } from "antd";
import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import * as lib from "@clusterio/lib";
@@ -71,7 +71,7 @@ export default function RoleViewPage() {
>
<PageHeader
title={role.name}
extra={<>
extra={<Space wrap>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
@@ -90,7 +90,7 @@ export default function RoleViewPage() {
<DeleteOutlined />
</Button>
</Popconfirm>}
</>}
</Space>}
/>
<Form.Item name="name" label="Name">
<Input disabled={!canUpdate}/>
+1 -1
View File
@@ -345,7 +345,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
};
return <div>
<SectionHeader title="Saves" extra={<Space>
<SectionHeader title="Saves" extra={<Space wrap>
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
<UploadButton disabled={hostOffline}>
Upload save
@@ -106,7 +106,7 @@ export default function UserViewPage() {
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
</Space>}
extra={<>
extra={<Space wrap>
{account.hasPermission("core.user.revoke_token") && (
account.name === userName || account.hasPermission("core.user.revoke_other_token")
) && <Button
@@ -146,7 +146,7 @@ export default function UserViewPage() {
<DeleteOutlined />
</Button>
</Popconfirm>}
</>}
</Space>}
/>
<Form
form={form}
@@ -249,6 +249,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
rowKey={(user) => user.name}
pagination={defaultPagination}
size={size}
scroll={{ x: "max-content" }}
onRow={(user) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("a")) {
+7
View File
@@ -69,6 +69,9 @@ export class Control extends lib.Link {
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
/** Cache for factorio versions to avoid repeat calls to the controller */
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
declare connector: ControlConnector;
constructor(
@@ -105,6 +108,10 @@ export class Control extends lib.Link {
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
}
requestFactorioVersions() {
return this.send(new lib.FactorioVersionsRequest());
}
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
this.accountRoles = event.roles??null;
this.emitAccountUpdate();
+17 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,25 +20,30 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
}
}
+2
View File
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
{ "path": "../../packages/ctl/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
@@ -76,6 +76,12 @@ end
-- min
-- max
function serialize.serialize_personal_logistic_slots(player)
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return nil
end
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
@@ -129,9 +135,18 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
return
end
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return
end
-- Load personal logistic slots
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
return
end
-- Remove old sections up to section_count
for i = logistic_point.sections_count, 1, -1 do
+20 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -20,28 +20,33 @@
"url": "https://github.com/clusterio/factorioClusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
"@sinclair/typebox": "catalog:",
"fs-extra": "catalog:"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
{ "path": "../../packages/controller/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
+22 -17
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,30 +20,35 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.1"
"@sinclair/typebox": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/express": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
+2
View File
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
{ "path": "../../packages/controller/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
+17 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,25 +20,30 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
"@sinclair/typebox": "catalog:",
"fs-extra": "catalog:"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
+2
View File
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
{ "path": "../../packages/controller/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,20 +20,23 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
@@ -2,6 +2,7 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
+21 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,29 +20,34 @@
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^8.1.0"
"@sinclair/typebox": "catalog:",
"fs-extra": "catalog:"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
{ "path": "../../packages/host/tsconfig.node.json" },
{ "path": "../../packages/controller/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
+32
View File
@@ -5,4 +5,36 @@ packages:
- "external_plugins/*/*"
- "test/"
catalog:
"@types/node": ^20.17.19
"@types/fs-extra": ^11.0.1
"@types/express": ^4.17.17
"@types/finalhandler": ^1.2.0
"@types/jsonwebtoken": ^9.0.9
"@types/set-blocking": ^2.0.0
"@types/react": ^18.3.18
"@types/ws": ^8.5.14
"@sinclair/typebox": ^0.30.4
"@swc/core": ^1.13.3
"antd": ^5.24.2
"react": ^18.2.0
"react-dom": ^18.2.0
"react-router-dom": ^6.29.0
"typescript": ^5.7.3
"webpack": ^5.98.0
"webpack-cli": ^5.1.4
"webpack-merge": ^5.9.0
"fs-extra": ^8.1.0
"express": ^5.1.0
"winston": ^3.17.0
"winston-transport": ^4.9.0
"winston-daily-rotate-file": ^4.7.1
"jsonwebtoken": ^9.0.2
"set-blocking": ^2.0.0
"jszip": ^3.10.1
"chalk": ^4.1.2
"phin": ^3.7.0
"ws": ^8.18.1
catalogMode: strict
linkWorkspacePackages: true
+134
View File
@@ -0,0 +1,134 @@
/**
* Publish any mods that need to be published
*/
"use strict";
const FormData = require("form-data");
const fs = require("fs-extra");
const yargs = require("yargs");
let DRY = false;
const modReleaseCache = new Map();
/**
* Gets a list of all releases for a mod
*/
async function requestModReleases(mod) {
const cache = modReleaseCache.get(mod);
if (cache) {
return cache;
}
// eslint-disable-next-line no-console
console.log(`Requesting current releases of ${mod}`);
const response = await fetch(`https://mods.factorio.com/api/mods/${mod}`);
if (!response.ok) {
throw new Error(`Failed request to mod portal api: ${response.statusText}\n${await response.text()}`);
}
const json = await response.json();
modReleaseCache.set(mod, json.releases);
return json.releases;
}
/**
* Upload a mod to the mod portal
*/
async function uploadModRelease(file, name) {
// eslint-disable-next-line no-console
console.log(`Uploading new release ${file}`);
if (DRY) {
return;
}
const headers = {
Authorization: `Bearer ${process.env.FACTORIO_TOKEN}`,
};
// Start the upload process by requesting an upload url
const initFormData = new FormData();
initFormData.append("mod", name);
const initResponse = await fetch("https://mods.factorio.com/api/v2/mods/releases/init_upload", {
method: "POST",
headers: headers,
body: initFormData,
});
if (!initResponse.ok) {
throw new Error(`Failed init mod upload: ${initResponse.statusText}\n${await initResponse.text()}`);
}
// Upload the new version of the mod
const uploadUrl = (await initResponse.json()).upload_url;
const uploadFormData = new FormData();
uploadFormData.append("file", fs.createReadStream(file));
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
headers: headers,
body: uploadFormData,
});
if (!uploadResponse.ok) {
throw new Error(`Failed mod upload: ${uploadResponse.statusText}\n${await uploadResponse.text()}`);
}
}
const regexModZip = /(\w+)_\d+.\d+.\d+\.zip/;
/**
* Publishes all mods that require publishing
*/
async function publishMods(dir) {
const files = await fs.readdir(dir);
const mods = files
.map(file => regexModZip.exec(file))
.filter(file => Boolean(file));
const uploading = [];
for (const mod of mods) {
const [file, name] = mod;
const releases = await requestModReleases(name);
if (!releases.some(release => release.file_name === file)) {
uploading.push(uploadModRelease(file, name));
} else {
// eslint-disable-next-line no-console
console.log(`Skipped ${file}`);
}
}
await Promise.all(uploading);
}
/**
* Main function for this script
*/
async function main() {
await yargs(process.argv.slice(2))
.scriptName("publish")
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not publish anything",
default: false,
type: "boolean",
})
.command(["factorio <mods-dir>", "$0 <mods-dir>"], "Publish factorio mods", yargs => {
yargs
.positional("mods-dir", {
describe: "Path to packed mods to publish",
normalize: true,
type: "string",
})
.demandOption("mods-dir", "A path to the mods dir must be specified");
}, async argv => {
DRY = argv.dry;
publishMods(argv.modsDir);
})
.strict()
.parse();
}
// Run main if started from command line
if (module === require.main) {
main();
}
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const assert = require("assert").strict;
/**
* Generate a flat array of tests from a matrix of inputs.
*
* @template {any[][]} T
* @param {T} arrays - A list of arrays representing the different values for each argument
* @returns {Array<{ [K in keyof T]: T[K][number] }>}
* An array of tuples, each containing one value from each input array.
*/
function testMatrix(...arrays) {
return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]);
}
/**
* Test that a class is round trip json serialisable across multiple test cases.
*
* @template {any[]} T - Constructor arguments
* @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods.
* @param {T[]} tests - The tests inputs to pass to the class constructor.
*/
function testRoundTripJsonSerialisable(Class, tests) {
for (const test of tests) {
const original = new Class(...test);
const serialised = JSON.stringify(original);
const reconstructed = Class.fromJSON(JSON.parse(serialised));
assert.deepEqual(reconstructed, original, JSON.stringify(test));
}
}
module.exports = {
testMatrix,
testRoundTripJsonSerialisable,
};
+107 -2
View File
@@ -1,9 +1,14 @@
"use strict";
const path = require("node:path");
const assert = require("assert").strict;
const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller");
const { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role } = require("@clusterio/lib");
const { EventEmitter } = require("stream");
const {
ControllerConfig, Address, RequestError,
InstanceConfig, SystemInfo, Role, ModPack,
} = require("@clusterio/lib");
class MockEvent {}
class MockInstanceConfig extends EventEmitter {
@@ -27,8 +32,10 @@ describe("controller/src/Controller", function() {
describe("class Controller", function() {
/** @type {Controller} */
let controller, mockInstanceConfig;
const controllerVersion = require("@clusterio/controller/package.json").version;
before(async function() {
controller = new Controller({}, [], new ControllerConfig("controller"));
const controllerConfig = new ControllerConfig("controller", { "controller.version": controllerVersion });
controller = new Controller({}, [], controllerConfig);
mockInstanceConfig = new MockInstanceConfig(new Map([
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
]));
@@ -233,6 +240,62 @@ describe("controller/src/Controller", function() {
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
});
describe(".checkRestartRequired()", function() {
beforeEach(function() {
// Would not be needed if controller setup was beforeEach
controller.config.restartRequired = false;
controller.config.set("controller.version", controllerVersion);
controller.config.set("global_chat.load_plugin", true);
controller.pluginInfos[0] = {
name: "global_chat",
requirePath: path.dirname(require.resolve("@clusterio/controller/package.json")),
version: controllerVersion,
};
});
it("returns false when no changes are present", async function() {
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns false when an unloaded plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
controller.config.set("global_chat.load_plugin", false);
controller.config.restartRequired = false; // Setting load_plugin requires a restart
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns true when the config flag is set", async function() {
controller.config.restartRequired = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when clusterio version changes", async function() {
controller.config.set("controller.version", "0.0.0");
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a controller plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a web plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
});
});
describe("migrations", function() {
describe("SystemInfo", function() {
@@ -314,6 +377,48 @@ describe("controller/src/Controller", function() {
]);
});
});
describe("ModPack", function() {
it("migrates builtin mod versions to X.Y.Z", function() {
const modPacks = Controller.migrateModPacks([{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
assert.deepEqual(modPacks, [{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
});
it("does nothing for upto date data", function() {
const modPack = ModPack.fromJSON({
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
});
const jsonString = JSON.stringify(modPack);
const modPackJson = JSON.parse(jsonString);
const modPackJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateModPacks([
modPackJson, modPackJson, modPackJson,
]);
assert.deepEqual(result, [
modPackJsonCopy, modPackJsonCopy, modPackJsonCopy,
]);
});
});
describe("Role", function() {
it("migrates renamed permissions", function() { // Alpha 17
const result = Controller.migrateRoles([
+13
View File
@@ -74,6 +74,19 @@ describe("controller/src/routes", function() {
assert.equal(await responses[1].text(), "test content");
});
});
describe("/api/cluster-name", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/cluster-name`;
});
it("should return the cluster name from config", async function() {
controller.mockConfigEntries.set("controller.name", "Test Cluster");
let response = await fetch(endpoint);
assert.equal(response.status, 200);
let data = await response.json();
assert.deepEqual(data, { name: "Test Cluster" });
});
});
describe("/api/upload-save", function() {
let endpoint;
beforeEach(function() {
File diff suppressed because it is too large. Load diff
+59
View File
@@ -6,6 +6,7 @@ const lib = require("@clusterio/lib");
const Host = require("@clusterio/host/dist/node/src/Host").default;
const Instance = require("@clusterio/host/dist/node/src/Instance").default;
const { _discoverInstances } = require("@clusterio/host/dist/node/src/Host");
const { HostConnector } = require("@clusterio/host/dist/node/host");
describe("Host testing", function() {
describe("discoverInstances()", function() {
@@ -115,5 +116,63 @@ describe("Host testing", function() {
]);
});
});
describe(".checkRestartRequired()", function() {
let host;
beforeEach(function() {
// This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent
const hostVersion = require("@clusterio/host/package.json").version;
const pluginInfos = [{
name: "global_chat",
requirePath: path.dirname(require.resolve("@clusterio/host/package.json")),
version: hostVersion,
}];
const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion });
const hostConnector = new HostConnector(hostConfig, undefined, pluginInfos);
host = new Host(hostConnector, hostConfig, undefined, pluginInfos);
});
it("returns false when no changes are present", async function() {
host.pluginInfos[0].hostEntrypoint = true;
host.pluginInfos[0].instanceEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, false);
assert.equal(host.config.restartRequired, false);
});
it("returns false when an unloaded plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].hostEntrypoint = true;
host.pluginInfos[0].instanceEntrypoint = true;
host.config.set("global_chat.load_plugin", false);
host.config.restartRequired = false; // Setting load_plugin requires a restart
const result = await host.checkRestartRequired();
assert.equal(result, false);
assert.equal(host.config.restartRequired, false);
});
it("returns true when the config flag is set", async function() {
host.config.restartRequired = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when clusterio version changes", async function() {
host.config.set("host.version", "0.0.0");
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when a host plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].hostEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when an instance plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].instanceEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
});
});
});
+141 -5
View File
@@ -13,8 +13,8 @@ const { wait } = lib;
const testStrings = require("../lib/factorio/test_strings");
const {
TestControl, TestControlConnector, url, controlToken, slowTest,
exec, execCtl, execCtlProcess, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir,
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
} = require("./index");
@@ -109,16 +109,59 @@ describe("Integration of Clusterio", function() {
describe("clusteriocontroller", function() {
describe("bootstrap generate-user-token", function() {
it("work for existing user", async function() {
await exec("node ../../packages/controller bootstrap generate-user-token test");
await execController("bootstrap generate-user-token test");
});
it("fails if user does not exist", async function() {
await assert.rejects(
exec("node ../../packages/controller bootstrap generate-user-token invalid")
execController("bootstrap generate-user-token invalid")
);
});
});
describe("bootstrap create-admin", function() {
it("should refuse to modify the database files when locked", async function() {
await assert.rejects(
execController("bootstrap create-admin BootstrapAdminTest")
);
});
it("should modify the database files when locked if bypass option is given", async function() {
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file");
} finally {
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
}
const json = await fs.readJSON(path.join(databaseDir, "users.json"));
assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true);
});
});
describe("config", function() {
it("can read the config file", async function() {
await execController("config list");
});
it("should refuse to modify the config file while locked", async function() {
await assert.rejects(
execController("config set controller.name ConfigEditTest")
);
});
it("should modify the config file while locked if bypass option is given", async function() {
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("config set controller.name ConfigEditTest2 --bypass-lock-file");
} finally {
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
}
const json = await fs.readJSON(path.join(controllerConfigPath));
assert.equal(json["controller.name"], "ConfigEditTest2");
});
});
describe("run", function() {
it("should handle resume of an active connection", async function() {
slowTest(this);
@@ -143,6 +186,38 @@ describe("Integration of Clusterio", function() {
await events.once(connectorB, "resume");
await connectorB.close(1000, "");
});
it("should refuse to start a second process while locked", async function() {
await assert.rejects(execController("run"));
});
it("should start a second process while locked if bypass option is given", async function() {
slowTest(this);
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("run --bypass-lock-file");
} catch (err) {
assert.equal(/Error: Server listening failed/.test(err.stderr), true);
}
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
});
it("should refuse to start when no users are loaded", async function() {
const dir = path.join("temp", "test", "empty_controller");
await fs.emptyDir(dir);
await assert.rejects(execController("run", { cwd: dir }));
});
it("should start when no users are loaded if bypass option given", async function() {
slowTest(this);
const dir = path.join("temp", "test", "empty_controller");
await fs.emptyDir(dir);
const child = await spawnNode(
"altController",
"../../../packages/controller run --no-check-user-count",
/Started controller/,
{ cwd: dir },
);
assert.ok(child.kill());
assert.equal(child.killed, true);
});
});
describe("queryLogRequestHandler", function() {
@@ -245,6 +320,67 @@ describe("Integration of Clusterio", function() {
await stopAltHost(hostProcess);
}
});
describe("config", function() {
it("can read the config file", async function() {
await execHost("config list");
});
it("should refuse to modify the config file while locked", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await assert.rejects(
execHost(`--config ${config} config set host.name ConfigEditTest`)
);
} finally {
await stopAltHost(hostProcess);
}
});
it("should modify the config file while locked if bypass option is given", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`);
} finally {
await stopAltHost(hostProcess);
}
const json = await fs.readJSON(path.join("temp", "test", config));
assert.equal(json["host.name"], "ConfigEditTest2");
});
});
describe("run", function() {
it("should refuse to start a second process while locked", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await assert.rejects(
execHost(`--config ${config} run`)
);
} finally {
await stopAltHost(hostProcess);
}
});
it("should start a second process while locked if bypass option is given", async function() {
slowTest(this);
let hostProcessA, hostProcessB; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcessA = await startAltHost();
hostProcessB = await spawnNode(
"alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/
);
} finally {
await stopAltHost(hostProcessA);
await stopAltHost(hostProcessB);
}
});
});
});
describe("clusterioctl", function() {
@@ -951,7 +1087,7 @@ describe("Integration of Clusterio", function() {
await execCtl("instance start 44");
const instance = (await getInstances()).get(44);
const isV2 = lib.integerFactorioVersion(instance.factorioVersion) > lib.integerFactorioVersion("2.0.0");
const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0");
const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, "");
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
await execCtl("instance stop 44");
+26 -15
View File
@@ -117,15 +117,14 @@ class TestHostConnector extends lib.WebSocketClientConnector {
}
}
// Mark that this test takes a lot of time, or depeneds on a test
// that takes a lot of time.
// Mark that this test takes a lot of time, or depends on a test that takes a lot of time.
function slowTest(test) {
if (process.env.FAST_TEST) {
test.skip();
}
test.timeout(20000);
test.timeout(30000);
}
async function get(urlPath) {
@@ -174,29 +173,31 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json");
let hostConfigPath = path.join("temp", "test", "config-host.json");
let controlConfigPath = path.join("temp", "test", "config-control.json");
async function exec(command, options = {}) {
function exec(command, options = {}) {
// Uncomment to show commands run in tests
// console.log(command);
options = { cwd: path.join("temp", "test"), ...options };
return await util.promisify(child_process.exec)(command, options);
return util.promisify(child_process.exec)(command, options);
}
async function execController(...args) {
function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
return await exec(...args);
return exec(...args);
}
async function execHost(...args) {
function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
return await exec(...args);
return exec(...args);
}
async function execCtlProcess(...args) {
function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
return await exec(...args);
return exec(...args);
}
let inExecCtl = false;
async function execCtl(command) {
inExecCtl = true;
process.chdir("temp/test");
try {
const initArgs = await initializeCtl(command, control.plugins, true);
@@ -209,9 +210,17 @@ async function execCtl(command) {
await targetCommand.run(initArgs.args, control);
} finally {
process.chdir("../..");
inExecCtl = false;
}
}
afterEach(function() {
if (inExecCtl) {
// This is needed in case a test times out and never runs the finally block
process.chdir("../..");
}
});
async function sendRcon(instanceId, command) {
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
}
@@ -220,7 +229,8 @@ function getControl() {
return control;
}
function spawn(name, cmd, waitFor) {
/** @returns {Promise<child_process.ChildProcess>} */
function spawn(name, cmd, waitFor, options = {}) {
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
@@ -233,7 +243,7 @@ function spawn(name, cmd, waitFor) {
return new Promise((resolve, reject) => {
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options });
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
@@ -257,8 +267,8 @@ function spawn(name, cmd, waitFor) {
});
}
async function spawnNode(name, cmd, waitFor) {
return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor);
function spawnNode(name, cmd, waitFor, options) {
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
}
before(async function() {
@@ -385,6 +395,7 @@ module.exports = {
TestControl,
TestControlConnector,
TestHostConnector,
execController,
execCtlProcess,
slowTest,
get,
+30 -11
View File
@@ -2,12 +2,14 @@
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { testMatrix } = require("../common");
const {
slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl,
} = require("./index");
const instId = 48;
const instName = "Integration";
const instSetConfig = `instance config set ${instName}`;
const instAltId = 49;
const instAltName = "IntegrationAlt";
@@ -52,7 +54,6 @@ describe("Clusterio Instance", function() {
// Exclude alt test instance from start-all to prevent interference
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
await execCtl(`instance start ${instAltName}`);
await sendRcon(instAltId, "/sc disable achievements");
});
after(async function() {
this.timeout(20000);
@@ -61,25 +62,42 @@ describe("Clusterio Instance", function() {
await execCtl(`instance stop ${instAltName}`);
await execCtl(`instance delete ${instAltName}`);
});
for (const savePatchingEnabled of [true, false]) {
describe(`Integration ${savePatchingEnabled ? "with" : "without"} save patching`, function() {
for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) {
const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`;
const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`;
async function allowSendingScriptCommands() {
if (!scriptCommandsEnabled) {
// Enable command to prevent "Attempted to use script command when disabled"
// This works because we only disable achievements on startup, this will break if we make it live update
await execCtl(`${instSetConfig} factorio.enable_script_commands true`);
}
}
describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() {
before(async function() {
this.timeout(20000);
// Create and start a patched save
await execCtl(`instance config set ${instName} factorio.enable_save_patching ${savePatchingEnabled}`);
await execCtl(`instance save create ${instName} ${savePatchingEnabled ? "patched" : "unpatched"}`);
await execCtl(`instance start ${instName} --save ${savePatchingEnabled ? "patched" : "unpatched"}.zip`);
await sendRcon(instId, "/sc disable achievements");
await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`);
await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`);
await execCtl(`instance save create ${instName}`);
await execCtl(`instance start ${instName}`);
});
after(async function() {
// Stop the instance
await execCtl(`instance stop ${instName}`);
});
describe("player event", function() {
describe("Disable achievements", function() {
it("should only disable achievements when script commands are enabled", async function() {
await allowSendingScriptCommands();
const res = await sendRcon(instId, "/sc rcon.print('Disabled')");
assert.equal(res === "Disabled\n", scriptCommandsEnabled);
});
});
describe("Player events", function() {
if (savePatchingEnabled) {
// IPC expects save patching to be enabled
it("should do nothing for an unknown type", async function() {
await assert.rejects(getUser("DoesNotExist"), "User should not exist");
await allowSendingScriptCommands();
await sendRcon(instId,
`/sc ${requireApi} api.send_json("player_event",` +
"{ type='invalid type', name='DoesNotExist' })"
@@ -87,6 +105,7 @@ describe("Clusterio Instance", function() {
await assert.rejects(getUser("DoesNotExist"), "User was created");
});
it("should respond to a player join and leave event", async function() {
await allowSendingScriptCommands();
await sendRcon(instId,
`/sc ${requireApi} api.send_json("player_event",` +
"{ type='join', name='JoiningPlayer' })"
@@ -102,7 +121,7 @@ describe("Clusterio Instance", function() {
});
}
it("should respond to a player ban and unban event", async function() {
await execCtl(`instance config set ${instName} factorio.sync_banlist bidirectional`);
await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`);
await sendRcon(instId, "/ban BannedPlayer");
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
const userBanned = await getUser("BannedPlayer");
@@ -119,7 +138,7 @@ describe("Clusterio Instance", function() {
});
it("should respond to a player promote and demote event", async function() {
// Because there is no admin list command this test will be different to the other two
await execCtl(`instance config set ${instName} factorio.sync_adminlist bidirectional`);
await execCtl(`${instSetConfig} factorio.sync_adminlist bidirectional`);
await sendRcon(instId, "/promote AdminPlayer");
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
const userPromote = await getUser("AdminPlayer");
@@ -141,7 +160,7 @@ describe("Clusterio Instance", function() {
);
});
it("should respond to a player whitelist add and remove event", async function() {
await execCtl(`instance config set ${instName} factorio.sync_whitelist bidirectional`);
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
await sendRcon(instId, "/whitelist add WhitelistPlayer");
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
const userAdded = await getUser("WhitelistPlayer");
+133
View File
@@ -0,0 +1,133 @@
/* eslint-disable node/no-sync */
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const { LockFile } = require("@clusterio/lib");
const baseDir = path.join("temp", "test", "lock_file");
describe("class LockFile", function () {
/** @type {string} */
let filePath;
/** @type {LockFile} */
let lock;
before(async function () {
await fs.ensureDir(baseDir);
});
beforeEach(function() {
filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`);
lock = new LockFile(filePath);
});
afterEach(async function () {
await fs.unlink(filePath).catch(() => {});
});
describe("acquire()", function () {
it("should create the lock file when acquiring", async function () {
await lock.acquire();
assert.ok(fs.existsSync(filePath));
});
it("should throw LockFileBusyError if called when not idle", async function () {
await lock.acquire();
await assert.rejects(lock.acquire(), { name: "LockFileBusyError" });
});
it("should remove stale lock file and acquire it", async function () {
await fs.writeFile(filePath, "999999\n"); // stale lock
await lock.acquire();
assert.ok(fs.existsSync(filePath));
});
it("should throw if existing lock file is not stale", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
});
describe("release()", function () {
it("should remove the lock file when releasing", async function () {
await lock.acquire();
await lock.release();
assert.ok(!fs.existsSync(filePath));
});
it("should throw LockFileBusyError if called when not acquired", async function () {
await assert.rejects(lock.release(), { name: "LockFileBusyError" });
});
});
describe("isHeld()", function () {
it("should return true only when lock file contains current PID", async function () {
await lock.acquire();
const held = await lock.isHeld();
assert.equal(held, true);
});
it("should return false when lock file does not exist", async function () {
const held = await lock.isHeld();
assert.equal(held, false);
});
it("should return false if lock file contains different PID", async function () {
await fs.writeFile(filePath, "999999\n"); // simulate another process
const held = await lock.isHeld();
assert.equal(held, false);
});
it("should return false if lock file was deleted while held", async function() {
await lock.acquire();
await fs.unlink(filePath);
const held = await lock.isHeld();
assert.equal(held, false);
});
});
describe("isStale()", function () {
it("should return true if lock file does not exist", async function () {
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return true for invalid PID in lock file", async function () {
await fs.writeFile(filePath, "abc\n"); // non-numeric PID
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return true for non-existent process PID", async function () {
await fs.writeFile(filePath, "999999\n"); // assume PID does not exist
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for current process PID", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
const stale = await lock.isStale();
assert.equal(stale, false);
});
});
describe("attach/detach exit handlers", function () {
it("should attach exit handler on acquire and detach on release", async function () {
await lock.acquire();
const listenersBefore = process.listeners("exit").slice();
assert.ok(listenersBefore.includes(lock.handleExitSync));
await lock.release();
const listenersAfter = process.listeners("exit").slice();
assert.ok(!listenersAfter.includes(lock.handleExitSync));
});
});
describe("handleExitSync()", function () {
it("should not throw if file already removed", function () {
assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file
});
});
});
+108 -102
View File
@@ -7,7 +7,7 @@ const JSZip = require("jszip"); // Added for creating mock zips
// Capture native fetch before any potential mocks are applied
const nativeFetch = global.fetch;
const { ModStore, ModInfo } = require("@clusterio/lib"); // Adjust path based on compiled output
const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output
const MODS_DIR = path.join("temp", "test", "mod_store", "mods");
const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json");
@@ -148,7 +148,7 @@ describe("lib/ModStore", function () {
assert.equal(modInfo.name, modName);
assert.equal(modInfo.version, modVersion);
// Check if the dependency was updated (indicating re-read)
assert(modInfo.dependencies.includes("? updated-dep"));
assert(modInfo.dependencySpecifications.includes("? updated-dep"));
});
it("should handle corrupted zip files gracefully", async function () {
@@ -271,7 +271,7 @@ describe("lib/ModStore", function () {
});
// Helper to create the mock fetch function for the Factorio Mod Portal API
function createMockPortalApiFetch(modsToDownloadMap, fetchCallsArray = null) {
function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) {
return async (url, options) => {
const urlString = url.toString();
if (fetchCallsArray) {
@@ -285,11 +285,12 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownloadMap.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
// Use assert or throw for clearer test failure messages
assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -309,7 +310,7 @@ describe("lib/ModStore", function () {
}
it("should download and add a single mod", async function () {
const modsToDownload = new Map([["download-mod-1", "1.1.0"]]);
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
@@ -327,10 +328,10 @@ describe("lib/ModStore", function () {
});
it("should download multiple mods", async function () {
const modsToDownload = new Map([
["multi-mod-a", "1.0.0"],
["multi-mod-b", "2.0.0"],
]);
const modsToDownload = [
{ name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") },
{ name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") },
];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
@@ -343,43 +344,34 @@ describe("lib/ModStore", function () {
assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip")));
assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip")));
});
it("should throw when no releases available", async function () {
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
it("should skip already downloaded mods", async function () {
const existingModName = "existing-mod";
const existingModVersion = "1.0.0";
const existingFilename = `${existingModName}_${existingModVersion}.zip`;
await createMockModZip(path.join(MODS_DIR, existingFilename), existingModName, existingModVersion);
modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1);
// Define fetch mock specific to this test
global.fetch = async (url, options) => {
const urlString = url.toString();
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => ({ name: name }));
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
// Fallback for unhandled fetches in this test
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
const modsToDownload = new Map([
[existingModName, existingModVersion], // Already present
["new-mod", "0.5.0"], // New mod
]);
let fetchCalls = [];
// Define fetch mock specific to this test (wraps original for tracking)
global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 2, "Should have 2 mods total");
assert(modStore.files.has(existingFilename));
assert(modStore.files.has("new-mod_0.5.0.zip"));
assert(await fs.pathExists(path.join(MODS_DIR, "new-mod_0.5.0.zip")));
const postCall = fetchCalls.find(call => call.options?.method === "POST");
assert(postCall, "POST call to API should have happened");
const requestedMods = (postCall.options.body.get("namelist") || "").split(",");
assert.deepEqual(requestedMods, ["new-mod"], "Only new-mod should be in the namelist");
await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion));
});
it("should skip built-in mods", async function () {
const modsToDownload = new Map([
["base", "1.1.0"], // Built-in
["core", "1.1.0"], // Built-in
["another-new-mod", "1.0.0"], // Not built-in
]);
const modsToDownload = [
{ name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in
];
let fetchCalls = [];
// Define fetch mock specific to this test
@@ -396,51 +388,8 @@ describe("lib/ModStore", function () {
assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist");
});
it("should handle Factorio version formatting (e.g., 1.1.100 -> 1.1)", async function () {
const modsToDownload = new Map([["version-test-mod", "1.0.0"]]);
let capturedApiUrl = null;
// Define fetch mock specific to this test
const testFetch = async (url, options) => {
const urlString = url.toString();
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
capturedApiUrl = urlString; // Capture URL
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
return {
name: name,
latest_release: {
download_url: `/download/${name}/${requestedVersion}`,
file_name: `${name}_${requestedVersion}.zip`,
info_json: { factorio_version: factorioVersion },
version: requestedVersion,
sha1: `sha1_${name}_${requestedVersion}`,
},
};
});
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
global.fetch = testFetch;
await modStore.downloadMods(modsToDownload, username, token, "1.1.100"); // Use full version
assert(capturedApiUrl, "Fetch to API mods should have been called");
const apiUrlParams = new URL(capturedApiUrl).searchParams;
assert.equal(apiUrlParams.get("version"), "1.1", "Factorio version should be truncated in API call");
});
it("should handle API error during mod info fetch", async function () {
const modsToDownload = new Map([["api-error-mod", "1.0.0"]]);
const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }];
// Define fetch mock specific to this test (simulates API error)
global.fetch = async (url, options) => {
@@ -463,11 +412,11 @@ describe("lib/ModStore", function () {
});
it("should handle download error for a specific mod", async function () {
const modsToDownload = new Map([
["good-mod", "1.0.0"], // This one should succeed
["bad-download-mod", "1.0.0"], // This one will fail
["another-good-mod", "1.0.0"], // This one should also succeed
]);
const modsToDownload = [
{ name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
{ name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail
{ name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
];
// Mock ModStore.downloadFile to fail for the specific mod
const originalDSF = ModStore.downloadFile;
@@ -496,10 +445,11 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -526,12 +476,12 @@ describe("lib/ModStore", function () {
});
it("should download mods in chunks", async function () {
const modsToDownload = new Map([
["chunk-mod-1", "1.0.0"],
["chunk-mod-2", "1.0.0"],
["chunk-mod-3", "1.0.0"],
["chunk-mod-4", "1.0.0"],
]);
const modsToDownload = [
{ name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") },
];
let postCalls = 0;
// Define fetch mock specific to this test
@@ -545,10 +495,11 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -570,7 +521,7 @@ describe("lib/ModStore", function () {
// Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call
assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods");
assert.equal(modStore.files.size, modsToDownload.size, "All chunked mods should be downloaded");
assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded");
assert(modStore.files.has("chunk-mod-1_1.0.0.zip"));
assert(modStore.files.has("chunk-mod-4_1.0.0.zip"));
});
@@ -843,6 +794,40 @@ describe("lib/ModStore", function () {
});
});
describe("fetchModReleases (static)", function() {
// Keep track of the original fetch for the live test
let originalFetch;
before(function() {
originalFetch = global.fetch;
});
after(function() {
global.fetch = originalFetch; // Restore original fetch after this suite
});
it("should fetch from the correct api url (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) };
};
const result = await ModStore.fetchModReleases("my-mod");
assert.equal(result.name, "my-mod");
});
it("should throw an error when the fetch fails (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) };
};
await assert.rejects(ModStore.fetchModReleases("my-mod"));
});
it("should fetch from the correct api url (live)", async function() {
global.fetch = nativeFetch;
const result = await ModStore.fetchModReleases("clusterio_lib");
assert(result.releases && result.releases.length > 1, "Failed to fetch releases");
});
});
// === Instance Methods ===
describe("Instance Methods", function () {
let modStore;
@@ -1004,5 +989,26 @@ describe("lib/ModStore", function () {
assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo");
});
});
describe("filterInstalled", function() {
it("should filter out installed mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, false), [
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
]);
});
it("should filter out uninstalled mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, true), [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
]);
});
});
});
});
+127
View File
@@ -0,0 +1,127 @@
const assert = require("assert").strict;
const { ValueCache, wait } = require("@clusterio/lib");
describe("ValueCache", function () {
describe("constructor", function () {
it("constructs with a fetch function", function() {
const fetcher = async () => 42;
const cache = new ValueCache(fetcher);
assert.ok(cache);
});
});
describe("get", function() {
it("throws TypeError for negative maxAgeMs", async function() {
const fetcher = async () => 42;
const cache = new ValueCache(fetcher);
assert.throws(() => cache.get(-1), err => {
assert.equal(err instanceof TypeError, true);
assert.match(String(err.message), /maxAgeMs/);
return true;
});
});
it("uses a default of 0 for maxAgeMs", async function() {
let callCount = 0;
const fetcher = async () => {
callCount += 1;
return callCount;
};
const cache = new ValueCache(fetcher);
const v1 = await cache.get();
const v2 = await cache.get();
assert.equal(v1, 1);
assert.equal(v2, 2);
assert.equal(callCount, 2);
});
it("returns cached value when within maxAgeMs", async function() {
let callCount = 0;
const fetcher = async () => {
callCount += 1;
return callCount;
};
const cache = new ValueCache(fetcher);
const v1 = await cache.get(1000);
const v2 = await cache.get(1000);
assert.equal(v1, 1);
assert.equal(v2, 1);
assert.equal(callCount, 1);
});
it("refreshes the cache when older than maxAgeMs", async function() {
let callCount = 0;
const fetcher = async () => {
callCount += 1;
return callCount;
};
const cache = new ValueCache(fetcher);
const v1 = await cache.get(50);
const v2 = await cache.get(1000); // Use cached value
await wait(100);
const v3 = await cache.get(50); // Stale after 100ms wait
const v4 = await cache.get(1000); // Use cached value
assert.equal(v1, 1);
assert.equal(v2, 1);
assert.equal(v3, 2);
assert.equal(v4, 2);
assert.equal(callCount, 2);
});
it("shares in-flight fetch between concurrent callers", async function() {
let callCount = 0;
let resolveFetcher;
const fetcher = () => (
new Promise(resolve => {
callCount += 1;
resolveFetcher = resolve;
})
);
const cache = new ValueCache(fetcher);
const p1 = cache.get(1000);
const p2 = cache.get(1000);
assert.equal(callCount, 1);
resolveFetcher(42);
const [v1, v2] = await Promise.all([p1, p2]);
assert.equal(v1, 42);
assert.equal(v2, 42);
assert.equal(callCount, 1);
});
it("propagates errors from the fetcher and clears ongoing fetch", async function() {
let callCount = 0;
const error = new Error("fetch failed");
const fetcher = async () => {
callCount += 1;
throw error;
};
const cache = new ValueCache(fetcher);
await assert.rejects(cache.get(), err => {
assert.equal(err, error);
return true;
});
assert.equal(callCount, 1);
// After a failure, a new attempt to fetch should be made
await assert.rejects(cache.get(), err => {
assert.equal(err, error);
return true;
});
assert.equal(callCount, 2);
});
});
});
+245 -2
View File
@@ -5,10 +5,226 @@ const path = require("path");
const lib = require("@clusterio/lib");
const libBuildMod = require("@clusterio/lib/build_mod");
const { ModInfo } = lib;
const { ModInfo, ModDependency } = lib;
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/ModInfo", function() {
describe("class ModDependency", function() {
describe(".getTypeFromPrefix()", function() {
it("should not throw for valid prefixes", function() {
assert.equal(ModDependency.getTypeFromPrefix("!"), "incompatible");
assert.equal(ModDependency.getTypeFromPrefix("?"), "optional");
assert.equal(ModDependency.getTypeFromPrefix("(?)"), "hidden");
assert.equal(ModDependency.getTypeFromPrefix("~"), "unordered");
assert.equal(ModDependency.getTypeFromPrefix(""), "required");
});
it("should throw for unknown prefixes", function() {
assert.throws(() => ModDependency.getTypeFromPrefix("invalid"));
});
});
describe("constructor", function() {
it("should accept specifications of name only", function() {
const dependency = new ModDependency("my-mod");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "required");
assert.equal(dependency.version, undefined);
});
it("should accept specifications of name and prefix", function() {
const dependency = new ModDependency("? my-mod");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "optional");
assert.equal(dependency.version, undefined);
});
it("should accept specifications of name and version", function() {
const dependency = new ModDependency("my-mod >= 1.2.3");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "required");
assert.notEqual(dependency.version, undefined);
assert.equal(dependency.version.equality, ">=");
assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3"));
});
it("should accept specifications of name, prefix and version", function() {
const dependency = new ModDependency("? my-mod >= 1.2.3");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "optional");
assert.notEqual(dependency.version, undefined);
assert.equal(dependency.version.equality, ">=");
assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3"));
});
it("should throw if no version equality is given", function() {
assert.throws(() => new ModDependency("my-mod 1.2.3"));
assert.throws(() => new ModDependency("? my-mod 1.2.3"));
});
it("should throw if invalid specification given", function() {
assert.throws(() => new ModDependency("? ? my-mod = 1.2.3"));
});
it("should throw if name contains spaces", function() {
// Apparently there are some legacy mods which include spaces
// Such as https://mods.factorio.com/mod/Explosive%20Excavation
this.skip();
assert.throws(() => new ModDependency("my mod"));
assert.throws(() => new ModDependency("? my mod"));
assert.throws(() => new ModDependency("? my mod >= 1.2.3"));
});
it("should not error when the name contains spaces", function() {
const dependencyReq = new ModDependency("my mod");
assert.equal(dependencyReq.name, "my mod");
assert.equal(dependencyReq.type, "required");
const dependencyOpt = new ModDependency("? my mod");
assert.equal(dependencyOpt.name, "my mod");
assert.equal(dependencyOpt.type, "optional");
const dependencyVer = new ModDependency("my mod >= 1.2.3");
assert.equal(dependencyVer.name, "my mod");
assert.equal(dependencyVer.type, "required");
assert.equal(dependencyVer.version.version, "1.2.3");
const dependencyOptVer = new ModDependency("? my mod >= 1.2.3");
assert.equal(dependencyOptVer.name, "my mod");
assert.equal(dependencyOptVer.type, "optional");
assert.equal(dependencyOptVer.version.version, "1.2.3");
});
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(ModDependency, testMatrix(
["my-mod", "? my-mod", "! my-mod", "my-mod > 1.0.0", "~ my-mod <= 2.0.0"], // Specification
));
});
});
describe("checkUnsatisfiedReason()", function() {
const mods = [
ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }),
];
it("should be undefined for incompatible being missing", function() {
const dependency = new ModDependency("! not-present");
assert.equal(dependency.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for incompatible being present", function() {
const dependency = new ModDependency("! my-mod");
assert.equal(dependency.checkUnsatisfiedReason(mods), "incompatible");
});
it("should undefined for optional / hidden being missing", function() {
const dependencyOptional = new ModDependency("? not-present");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("(?) not-present");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should undefined for optional / hidden being present", function() {
const dependencyOptional = new ModDependency("? my-mod");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("(?) my-mod");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for optional / hidden being present but wrong version", function() {
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version");
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version");
});
it("should undefined for unordered / required being present", function() {
const dependencyOptional = new ModDependency("~ my-mod");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("my-mod");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for unordered / required being present but wrong version", function() {
const dependencyOptional = new ModDependency("~ my-mod > 2.0.0");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version");
const dependencyHidden = new ModDependency("my-mod > 2.0.0");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version");
});
it("should give reason for unordered / required being missing", function() {
const dependencyOptional = new ModDependency("~ not-present");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "missing_dependency");
const dependencyHidden = new ModDependency("not-present");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "missing_dependency");
});
});
describe("isSatisfied()", function() {
const mods = [
ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }),
];
it("should pass for incompatible being missing", function() {
const dependency = new ModDependency("! not-present");
assert.equal(dependency.isSatisfied(mods), true);
});
it("should fail for incompatible being present", function() {
const dependency = new ModDependency("! my-mod");
assert.equal(dependency.isSatisfied(mods), false);
});
it("should pass for optional / hidden being missing", function() {
const dependencyOptional = new ModDependency("? not-present");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) not-present");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should pass for optional / hidden being present", function() {
const dependencyOptional = new ModDependency("? my-mod");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) my-mod");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should fail for optional / hidden being present but wrong version", function() {
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
it("should pass for unordered / required being present", function() {
const dependencyOptional = new ModDependency("~ my-mod");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("my-mod");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should fail for unordered / required being present but wrong version", function() {
const dependencyOptional = new ModDependency("~ my-mod > 2.0.0");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("my-mod > 2.0.0");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
it("should fail for unordered / required being missing", function() {
const dependencyOptional = new ModDependency("~ not-present");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("not-present");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
});
describe("incompatible", function() {
it("should return true iff the type is incompatible", function() {
const dependencyIncompatible = new ModDependency("! my-mod");
assert.equal(dependencyIncompatible.incompatible, true);
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.incompatible, false, test);
}
});
});
describe("required", function() {
it("should return true iff the dependency is required", function() {
for (const test of ["my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, true, test);
}
for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, false, test);
}
});
});
describe("optional", function() {
it("should return true iff the dependency is optional", function() {
for (const test of ["? my-mod", "(?) my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, true, test);
}
for (const test of ["! my-mod", "my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, false, test);
}
});
});
});
describe("class ModInfo", function() {
it("should round trip serialize", function() {
const validate = lib.compile(ModInfo.jsonSchema);
@@ -70,7 +286,16 @@ describe("lib/data/ModInfo", function() {
factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion);
assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions);
});
it("should provide the original string specifications for dependencies", function() {
const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] });
assert.deepEqual(mod.dependencySpecifications, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"]);
});
it("should parse the provided dependency specifications", function() {
const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] });
assert.deepEqual(mod.dependencies,
["UltraMod", "SuperLib >= 1.00", "! bad-mod"].map(dep => new ModDependency(dep))
);
});
it("should load from a mod zip file", async function() {
await libBuildMod.build({
build: true,
@@ -100,5 +325,23 @@ describe("lib/data/ModInfo", function() {
})
);
});
describe("checkDependencySatisfaction()", function() {
it("should return undefined when satisfied", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "1.0.0" }]), undefined);
});
it("should return incompatible when there is an incompatibility", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "a", version: "1.0.0" }]), "incompatible");
});
it("should return missing dependency when missing but no incompatibly", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "c", version: "1.0.0" }]), "missing_dependency");
});
it("should return wrong version when wrong but not missing or incompatible", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "2.0.0" }]), "wrong_version");
});
});
});
});
+5 -5
View File
@@ -397,16 +397,16 @@ describe("lib/data/ModPack", function() {
it("should work with versions before 2.0", function() {
const builtinMods = ModPack.getBuiltinMods("1.1");
assert.deepEqual(builtinMods, [
{ name: "base", enabled: true, version: "1.1" },
{ name: "base", enabled: true, version: "1.1.0" },
]);
});
it("should work with versions after 2.0", function() {
const builtinMods = ModPack.getBuiltinMods("2.0");
assert.deepEqual(builtinMods, [
{ name: "base", enabled: true, version: "2.0" },
{ name: "elevated-rails", enabled: false, version: "2.0" },
{ name: "quality", enabled: false, version: "2.0" },
{ name: "space-age", enabled: false, version: "2.0" },
{ name: "base", enabled: true, version: "2.0.0" },
{ name: "elevated-rails", enabled: false, version: "2.0.0" },
{ name: "quality", enabled: false, version: "2.0.0" },
{ name: "space-age", enabled: false, version: "2.0.0" },
]);
});
});
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more