mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898b4b738c | ||
|
|
15fc41a801 | ||
|
|
fc0279ad11 | ||
|
|
9bf817e665 | ||
|
|
6489fa4048 | ||
|
|
22850b7cd0 | ||
|
|
f65c8fb9a0 | ||
|
|
3232da71e2 | ||
|
|
c9fc4f1c8f | ||
|
|
0c56c56f74 | ||
|
|
2928b9b7c5 | ||
|
|
1fc6971278 | ||
|
|
2679851d66 | ||
|
|
ab86cd2b1c | ||
|
|
02fce63e11 | ||
|
|
8981a018b0 | ||
|
|
bbf5df6d01 | ||
|
|
6f68cf74a7 | ||
|
|
07f501a67a | ||
|
|
4caf06aafc | ||
|
|
4901d76ebe | ||
|
|
6b0dd4e9cf | ||
|
|
a2c38ad4e8 | ||
|
|
438ccbdc7f | ||
|
|
d6c5430e0c | ||
|
|
d48b731ea4 | ||
|
|
bccb6527a7 | ||
|
|
00cf0f59ec | ||
|
|
3a823bee01 | ||
|
|
98650b9bfb | ||
|
|
a781af5b6a | ||
|
|
f202a0975a | ||
|
|
ea4280d184 | ||
|
|
c519ee1389 | ||
|
|
c61b9dbe60 | ||
|
|
77f4aa6296 | ||
|
|
86a9084d56 | ||
|
|
43def44136 | ||
|
|
8c1be7210e | ||
|
|
24e290746d | ||
|
|
d52931a326 | ||
|
|
73cc019d2f | ||
|
|
41151d47f0 | ||
|
|
3fbd9a995d | ||
|
|
af299bc08d | ||
|
|
732cee4837 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
@@ -30,8 +30,12 @@ dist/
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
!*Factorio*.ts
|
||||
!*Factorio*.tsx
|
||||
!*Factorio*.js
|
||||
/factorio
|
||||
!/test/file/factorio
|
||||
!/test/file/html/*.html
|
||||
!/test/lib/factorio
|
||||
!/packages/lib/src/factorio
|
||||
/instances
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
+3
-2
@@ -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/*",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: string, sha1?: string }) {
|
||||
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
|
||||
@@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
|
||||
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
|
||||
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
|
||||
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
|
||||
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
|
||||
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
|
||||
@@ -124,7 +126,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;
|
||||
@@ -28,6 +28,8 @@ import HostConnection from "./HostConnection";
|
||||
import HostInfo from "./HostInfo";
|
||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||
import ControllerRouter from "./ControllerRouter";
|
||||
import PlayerRoutingStore from "./PlayerRoutingStore";
|
||||
import PlayerProxyServer from "./PlayerProxyServer";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -76,6 +78,8 @@ export default class Controller {
|
||||
trustedProxies: BlockList;
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
playerRoutingStore: PlayerRoutingStore;
|
||||
playerProxyServer: PlayerProxyServer | null = null;
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions = new lib.SubscriptionController();
|
||||
@@ -105,6 +109,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 +141,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(
|
||||
@@ -206,6 +213,9 @@ export default class Controller {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.config = config;
|
||||
this.playerRoutingStore = new PlayerRoutingStore(
|
||||
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
|
||||
);
|
||||
|
||||
this.app = express();
|
||||
this.app.locals.controller = this;
|
||||
@@ -339,6 +349,17 @@ export default class Controller {
|
||||
// Load plugins
|
||||
await this.loadPlugins();
|
||||
|
||||
await this.playerRoutingStore.load();
|
||||
const playerProxyPort = this.config.get("controller.player_proxy_port");
|
||||
if (playerProxyPort !== null) {
|
||||
this.playerProxyServer = new PlayerProxyServer(
|
||||
this,
|
||||
logger.child({ component: "player_proxy" }),
|
||||
playerProxyPort,
|
||||
);
|
||||
await this.playerProxyServer.start();
|
||||
}
|
||||
|
||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||
logger.log({
|
||||
@@ -481,6 +502,11 @@ export default class Controller {
|
||||
|
||||
await lib.invokeHook(this.plugins, "onShutdown");
|
||||
|
||||
if (this.playerProxyServer) {
|
||||
await this.playerProxyServer.stop();
|
||||
this.playerProxyServer = null;
|
||||
}
|
||||
|
||||
await this.wsServer.stop();
|
||||
|
||||
let stopTasks = [];
|
||||
@@ -549,19 +575,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 +586,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);
|
||||
@@ -619,6 +674,8 @@ export default class Controller {
|
||||
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||
}
|
||||
|
||||
await this.playerRoutingStore.save();
|
||||
|
||||
await lib.invokeHook(this.plugins, "onSaveData");
|
||||
}
|
||||
|
||||
@@ -673,6 +730,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 => {
|
||||
@@ -817,6 +885,87 @@ export default class Controller {
|
||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||
}
|
||||
|
||||
resolvePlayerRoute(username: string): { hostId: number; instanceId: number; publicAddress: string; gamePort: number } | null {
|
||||
const key = username.toLowerCase();
|
||||
let route = this.playerRoutingStore.get(key);
|
||||
let instance: InstanceInfo | undefined;
|
||||
|
||||
if (!route) {
|
||||
const fallbackId = this.config.get("controller.player_proxy_default_instance_id");
|
||||
if (fallbackId !== null) {
|
||||
instance = this.instances.get(fallbackId);
|
||||
if (instance) {
|
||||
route = {
|
||||
name: username,
|
||||
instanceId: instance.id,
|
||||
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
||||
gamePort: instance.gamePort ?? 0,
|
||||
timestampMs: Date.now(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
instance = this._chooseFallbackInstance();
|
||||
if (instance) {
|
||||
route = {
|
||||
name: username,
|
||||
instanceId: instance.id,
|
||||
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
||||
gamePort: instance.gamePort ?? 0,
|
||||
timestampMs: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
instance = instance ?? this.instances.get(route.instanceId);
|
||||
if (!instance || instance.isDeleted) {
|
||||
this.playerRoutingStore.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null || route.hostId !== hostId) {
|
||||
this.playerRoutingStore.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = this.hosts.get(hostId);
|
||||
if (!host || !host.connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gamePort = instance.gamePort ?? route.gamePort;
|
||||
const publicAddress = host.publicAddress || host.remoteAddress;
|
||||
if (!gamePort || !publicAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hostId,
|
||||
instanceId: instance.id,
|
||||
publicAddress,
|
||||
gamePort,
|
||||
};
|
||||
}
|
||||
|
||||
private _chooseFallbackInstance() {
|
||||
for (const instance of this.instances.values()) {
|
||||
if (instance.status === "running" && !instance.isDeleted) {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
for (const instance of this.instances.values()) {
|
||||
if (!instance.isDeleted && instance.config.get("instance.assigned_host") !== null) {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance by ID for a request
|
||||
*
|
||||
@@ -1127,7 +1276,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
this._controller.playerRoutingStore.dropHost(this.id);
|
||||
});
|
||||
|
||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||
@@ -81,6 +82,7 @@ export default class HostConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
||||
this.handle(lib.InstancePlayerRouteUpdateEvent, this.handleInstancePlayerRouteUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
||||
@@ -354,6 +356,30 @@ export default class HostConnection extends BaseConnection {
|
||||
});
|
||||
}
|
||||
|
||||
async handleInstancePlayerRouteUpdateEvent(event: lib.InstancePlayerRouteUpdateEvent, src: lib.Address) {
|
||||
const instanceId = src.id;
|
||||
if (event.instanceId !== instanceId) {
|
||||
logger.warn(
|
||||
`Received route update for instance ${event.instanceId} from ${instanceId}, ignoring mismatched id`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.hostId !== this.id) {
|
||||
logger.warn(
|
||||
`Received route update for host ${event.hostId} from ${this.id}, ignoring mismatched host`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._controller.playerRoutingStore.update(
|
||||
event.name,
|
||||
instanceId,
|
||||
this.id,
|
||||
event.gamePort,
|
||||
event.timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
||||
if (src.type !== lib.Address.instance) {
|
||||
return;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import dgram from "dgram";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller";
|
||||
|
||||
const TYPE_MASK = 0x1f;
|
||||
const FLAG_MASK = 0xe0;
|
||||
|
||||
const TYPE_CONNECTION_REQUEST = 2;
|
||||
const TYPE_CONNECTION_REQUEST_REPLY = 3;
|
||||
const TYPE_CONNECTION_REQUEST_REPLY_CONFIRM = 4;
|
||||
|
||||
const SESSION_IDLE_TIMEOUT_MS = 30_000;
|
||||
const MAX_PENDING_PACKETS = 64;
|
||||
const MAX_PACKET_SIZE = 1400;
|
||||
|
||||
type ClientKey = string;
|
||||
|
||||
type RouteTarget = {
|
||||
hostId: number;
|
||||
instanceId: number;
|
||||
publicAddress: string;
|
||||
gamePort: number;
|
||||
};
|
||||
|
||||
type ClientSession = {
|
||||
clientAddress: string;
|
||||
clientPort: number;
|
||||
lastSeenMs: number;
|
||||
pending: Buffer[];
|
||||
upstream: dgram.Socket | null;
|
||||
route: RouteTarget | null;
|
||||
requestBuffer?: Buffer;
|
||||
requestFlags?: number;
|
||||
requestMessageIdAndFlags?: number;
|
||||
requestVersion?: ApplicationVersion;
|
||||
clientRequestId?: number;
|
||||
proxyServerId?: number;
|
||||
pendingConfirm?: Buffer;
|
||||
confirmServerIdOffset?: number;
|
||||
serverConnectionId?: number;
|
||||
username?: string;
|
||||
activating?: Promise<void>;
|
||||
};
|
||||
|
||||
type ApplicationVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
sub: number;
|
||||
build: number;
|
||||
};
|
||||
|
||||
class BufferReader {
|
||||
private _offset = 0;
|
||||
constructor(private _buffer: Buffer) {}
|
||||
|
||||
get offset() {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
readUInt8() {
|
||||
const value = this._buffer.readUInt8(this._offset);
|
||||
this._offset += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
readUInt16LE() {
|
||||
const value = this._buffer.readUInt16LE(this._offset);
|
||||
this._offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
readUInt32LE() {
|
||||
const value = this._buffer.readUInt32LE(this._offset);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
readSpaceOptimized() {
|
||||
const value = this.readUInt8();
|
||||
if (value === 0xff) {
|
||||
return this.readUInt16LE();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
readContainerSize() {
|
||||
const value = this.readUInt8();
|
||||
if (value === 0xff) {
|
||||
return this.readUInt32LE();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
readBytes(length: number) {
|
||||
const start = this._offset;
|
||||
const end = start + length;
|
||||
if (end > this._buffer.length) {
|
||||
throw new Error("Buffer too short");
|
||||
}
|
||||
this._offset = end;
|
||||
return this._buffer.slice(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
class BufferWriter {
|
||||
private _parts: Buffer[] = [];
|
||||
|
||||
writeUInt8(value: number) {
|
||||
const buf = Buffer.allocUnsafe(1);
|
||||
buf.writeUInt8(value, 0);
|
||||
this._parts.push(buf);
|
||||
}
|
||||
|
||||
writeUInt16LE(value: number) {
|
||||
const buf = Buffer.allocUnsafe(2);
|
||||
buf.writeUInt16LE(value, 0);
|
||||
this._parts.push(buf);
|
||||
}
|
||||
|
||||
writeUInt32LE(value: number) {
|
||||
const buf = Buffer.allocUnsafe(4);
|
||||
buf.writeUInt32LE(value >>> 0, 0);
|
||||
this._parts.push(buf);
|
||||
}
|
||||
|
||||
writeSpaceOptimized(value: number) {
|
||||
if (value >= 0xff) {
|
||||
this.writeUInt8(0xff);
|
||||
this.writeUInt16LE(value);
|
||||
} else {
|
||||
this.writeUInt8(value);
|
||||
}
|
||||
}
|
||||
|
||||
concat() {
|
||||
return Buffer.concat(this._parts);
|
||||
}
|
||||
}
|
||||
|
||||
function clientKey(address: string, port: number): ClientKey {
|
||||
return `${address}:${port}`;
|
||||
}
|
||||
|
||||
export default class PlayerProxyServer {
|
||||
private _socket: dgram.Socket | null = null;
|
||||
private _sessions = new Map<ClientKey, ClientSession>();
|
||||
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _closed = false;
|
||||
|
||||
constructor(
|
||||
private _controller: Controller,
|
||||
private _logger: lib.Logger,
|
||||
private _port: number,
|
||||
) {}
|
||||
|
||||
async start() {
|
||||
if (this._socket || this._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket("udp4");
|
||||
this._socket = socket;
|
||||
socket.on("message", this._handleClientMessage.bind(this));
|
||||
socket.on("error", err => {
|
||||
this._logger.error(`Player proxy socket error: ${err.message}`);
|
||||
this.stop().catch(() => {});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("listening", resolve);
|
||||
socket.once("error", reject);
|
||||
socket.bind(this._port, "0.0.0.0");
|
||||
});
|
||||
|
||||
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
|
||||
this._cleanupTimer.unref();
|
||||
this._logger.info(`Player proxy listening on UDP ${this._port}`);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
this._closed = true;
|
||||
|
||||
if (this._cleanupTimer) {
|
||||
clearInterval(this._cleanupTimer);
|
||||
this._cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
for (const session of this._sessions.values()) {
|
||||
session.upstream?.close();
|
||||
}
|
||||
this._sessions.clear();
|
||||
|
||||
if (this._socket) {
|
||||
await new Promise<void>(resolve => this._socket!.close(() => resolve()));
|
||||
this._socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleClientMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
if (this._closed || !this._socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = clientKey(rinfo.address, rinfo.port);
|
||||
let session = this._sessions.get(key);
|
||||
if (!session) {
|
||||
session = this._createSession(rinfo.address, rinfo.port);
|
||||
this._sessions.set(key, session);
|
||||
}
|
||||
session.lastSeenMs = Date.now();
|
||||
|
||||
let type: number;
|
||||
try {
|
||||
type = message.readUInt8(0) & TYPE_MASK;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === TYPE_CONNECTION_REQUEST) {
|
||||
this._handleConnectionRequest(session, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
||||
this._handleConnectionRequestReplyConfirm(session, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.route || !session.upstream) {
|
||||
this._queuePending(session, message);
|
||||
return;
|
||||
}
|
||||
|
||||
this._flushPending(session);
|
||||
session.upstream.send(message, session.route.gamePort, session.route.publicAddress);
|
||||
}
|
||||
|
||||
private _createSession(address: string, port: number): ClientSession {
|
||||
return {
|
||||
clientAddress: address,
|
||||
clientPort: port,
|
||||
lastSeenMs: Date.now(),
|
||||
pending: [],
|
||||
upstream: null,
|
||||
route: null,
|
||||
};
|
||||
}
|
||||
|
||||
private _handleConnectionRequest(session: ClientSession, message: Buffer) {
|
||||
let parsed: {
|
||||
flags: number;
|
||||
messageIdAndFlags: number;
|
||||
version: ApplicationVersion;
|
||||
requestId: number;
|
||||
};
|
||||
try {
|
||||
parsed = this._parseConnectionRequest(message);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to parse connection request: ${err.message ?? err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
session.requestBuffer = message;
|
||||
session.requestFlags = parsed.flags;
|
||||
session.requestMessageIdAndFlags = parsed.messageIdAndFlags;
|
||||
session.requestVersion = parsed.version;
|
||||
session.clientRequestId = parsed.requestId;
|
||||
if (!session.proxyServerId) {
|
||||
session.proxyServerId = Math.floor(Math.random() * 2 ** 32);
|
||||
}
|
||||
|
||||
const reply = this._buildConnectionRequestReply(session);
|
||||
this._socket?.send(reply, session.clientPort, session.clientAddress);
|
||||
}
|
||||
|
||||
private _handleConnectionRequestReplyConfirm(session: ClientSession, message: Buffer) {
|
||||
let parsed: { username: string; serverIdOffset: number };
|
||||
try {
|
||||
parsed = this._parseConnectionRequestReplyConfirm(message);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to parse connection confirm: ${err.message ?? err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
session.username = parsed.username;
|
||||
session.pendingConfirm = message;
|
||||
session.confirmServerIdOffset = parsed.serverIdOffset;
|
||||
|
||||
if (session.route || session.activating) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.activating = this._activateRoute(session).finally(() => {
|
||||
session.activating = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _buildConnectionRequestReply(session: ClientSession) {
|
||||
const flags = session.requestFlags ?? 0;
|
||||
const version = session.requestVersion ?? { major: 0, minor: 0, sub: 0, build: 0 };
|
||||
const requestId = session.clientRequestId ?? 0;
|
||||
const serverId = session.proxyServerId ?? 0;
|
||||
|
||||
const writer = new BufferWriter();
|
||||
writer.writeUInt8((flags & FLAG_MASK) | TYPE_CONNECTION_REQUEST_REPLY);
|
||||
writer.writeSpaceOptimized(version.major);
|
||||
writer.writeSpaceOptimized(version.minor);
|
||||
writer.writeSpaceOptimized(version.sub);
|
||||
writer.writeUInt32LE(version.build);
|
||||
writer.writeUInt32LE(requestId);
|
||||
writer.writeUInt32LE(serverId);
|
||||
writer.writeUInt32LE(MAX_PACKET_SIZE);
|
||||
return writer.concat();
|
||||
}
|
||||
|
||||
private _parseConnectionRequest(buffer: Buffer) {
|
||||
const reader = new BufferReader(buffer);
|
||||
const header = reader.readUInt8();
|
||||
const type = header & TYPE_MASK;
|
||||
if (type !== TYPE_CONNECTION_REQUEST) {
|
||||
throw new Error("Unexpected message type");
|
||||
}
|
||||
|
||||
const flags = header & FLAG_MASK;
|
||||
const messageIdAndFlags = reader.readUInt16LE();
|
||||
const version = {
|
||||
major: reader.readSpaceOptimized(),
|
||||
minor: reader.readSpaceOptimized(),
|
||||
sub: reader.readSpaceOptimized(),
|
||||
build: reader.readUInt32LE(),
|
||||
};
|
||||
const requestId = reader.readUInt32LE();
|
||||
return { flags, messageIdAndFlags, version, requestId };
|
||||
}
|
||||
|
||||
private _parseConnectionRequestReplyConfirm(buffer: Buffer) {
|
||||
const reader = new BufferReader(buffer);
|
||||
const header = reader.readUInt8();
|
||||
const type = header & TYPE_MASK;
|
||||
if (type !== TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
||||
throw new Error("Unexpected message type");
|
||||
}
|
||||
reader.readUInt16LE();
|
||||
reader.readUInt32LE(); // client request id
|
||||
const serverIdOffset = reader.offset;
|
||||
reader.readUInt32LE();
|
||||
reader.readUInt32LE(); // instance id
|
||||
const nameLength = reader.readContainerSize();
|
||||
const nameBuffer = reader.readBytes(nameLength);
|
||||
const username = nameBuffer.toString("utf8");
|
||||
return { username, serverIdOffset };
|
||||
}
|
||||
|
||||
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
|
||||
if (!this._socket || !session.route) {
|
||||
return;
|
||||
}
|
||||
|
||||
let type: number;
|
||||
try {
|
||||
type = data.readUInt8(0) & TYPE_MASK;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === TYPE_CONNECTION_REQUEST_REPLY && session.serverConnectionId === undefined) {
|
||||
try {
|
||||
const serverConnectionId = this._parseConnectionRequestReply(data);
|
||||
session.serverConnectionId = serverConnectionId;
|
||||
this._sendPendingConfirm(session);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to parse upstream reply: ${err.message ?? err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._socket.send(data, session.clientPort, session.clientAddress);
|
||||
}
|
||||
|
||||
private _parseConnectionRequestReply(buffer: Buffer) {
|
||||
const reader = new BufferReader(buffer);
|
||||
const header = reader.readUInt8();
|
||||
const type = header & TYPE_MASK;
|
||||
if (type !== TYPE_CONNECTION_REQUEST_REPLY) {
|
||||
throw new Error("Unexpected message type");
|
||||
}
|
||||
reader.readSpaceOptimized();
|
||||
reader.readSpaceOptimized();
|
||||
reader.readSpaceOptimized();
|
||||
reader.readUInt32LE();
|
||||
reader.readUInt32LE(); // client request id
|
||||
return reader.readUInt32LE();
|
||||
}
|
||||
|
||||
private async _activateRoute(session: ClientSession) {
|
||||
if (!session.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = this._controller.resolvePlayerRoute(session.username);
|
||||
if (!route) {
|
||||
this._logger.warn(`No route for ${session.username}, dropping client ${session.clientAddress}:${session.clientPort}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceReady = await this._ensureInstanceRunning(route.instanceId);
|
||||
if (!instanceReady) {
|
||||
this._logger.warn(`Instance ${route.instanceId} not ready for ${session.username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
session.route = route;
|
||||
if (!session.upstream) {
|
||||
const upstream = dgram.createSocket("udp4");
|
||||
session.upstream = upstream;
|
||||
upstream.on("message", data => this._handleUpstreamMessage(session, data));
|
||||
upstream.on("error", err => {
|
||||
this._logger.warn(
|
||||
`Upstream error for ${session.clientAddress}:${session.clientPort}: ${err.message}`
|
||||
);
|
||||
upstream.close();
|
||||
this._sessions.delete(clientKey(session.clientAddress, session.clientPort));
|
||||
});
|
||||
}
|
||||
|
||||
if (session.requestBuffer) {
|
||||
session.upstream.send(session.requestBuffer, route.gamePort, route.publicAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private async _ensureInstanceRunning(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
if (!instance || instance.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
if (instance.status === "running") {
|
||||
return true;
|
||||
}
|
||||
if (instance.config.get("instance.assigned_host") === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message ?? err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + SESSION_IDLE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const current = this._controller.instances.get(instanceId);
|
||||
if (!current || current.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
if (current.status === "running") {
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _sendPendingConfirm(session: ClientSession) {
|
||||
if (!session.pendingConfirm || session.confirmServerIdOffset === undefined || !session.upstream || !session.route) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.serverConnectionId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = Buffer.from(session.pendingConfirm);
|
||||
payload.writeUInt32LE(session.serverConnectionId, session.confirmServerIdOffset);
|
||||
session.upstream.send(payload, session.route.gamePort, session.route.publicAddress);
|
||||
session.pendingConfirm = undefined;
|
||||
this._flushPending(session);
|
||||
}
|
||||
|
||||
private _queuePending(session: ClientSession, message: Buffer) {
|
||||
if (session.pending.length >= MAX_PENDING_PACKETS) {
|
||||
session.pending.shift();
|
||||
}
|
||||
session.pending.push(message);
|
||||
}
|
||||
|
||||
private _flushPending(session: ClientSession) {
|
||||
if (!session.route || !session.upstream || !session.pending.length) {
|
||||
return;
|
||||
}
|
||||
for (const pending of session.pending) {
|
||||
session.upstream.send(pending, session.route.gamePort, session.route.publicAddress);
|
||||
}
|
||||
session.pending = [];
|
||||
}
|
||||
|
||||
private _cleanupSessions() {
|
||||
const now = Date.now();
|
||||
for (const [key, session] of this._sessions) {
|
||||
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
|
||||
session.upstream?.close();
|
||||
this._sessions.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "fs-extra";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type StoredRoute = {
|
||||
name: string;
|
||||
instanceId: number;
|
||||
hostId: number;
|
||||
gamePort: number;
|
||||
timestampMs: number;
|
||||
};
|
||||
|
||||
export default class PlayerRoutingStore {
|
||||
private _routes = new Map<string, StoredRoute>();
|
||||
private _dirty = false;
|
||||
|
||||
constructor(private _filePath: string) {}
|
||||
|
||||
get(name: string): StoredRoute | undefined {
|
||||
return this._routes.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
update(name: string, instanceId: number, hostId: number, gamePort: number, timestampMs: number) {
|
||||
const key = name.toLowerCase();
|
||||
this._routes.set(key, { name, instanceId, hostId, gamePort, timestampMs });
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
delete(name: string) {
|
||||
const key = name.toLowerCase();
|
||||
if (this._routes.delete(key)) {
|
||||
this._dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
dropHost(hostId: number) {
|
||||
let removed = false;
|
||||
for (const [key, route] of this._routes) {
|
||||
if (route.hostId === hostId) {
|
||||
this._routes.delete(key);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
this._dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const routes = JSON.parse(await fs.readFile(this._filePath, { encoding: "utf8" })) as StoredRoute[];
|
||||
this._routes.clear();
|
||||
for (const route of routes) {
|
||||
if (!route || !route.name) {
|
||||
continue;
|
||||
}
|
||||
this._routes.set(route.name.toLowerCase(), route);
|
||||
}
|
||||
this._dirty = false;
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this._dirty) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.stringify([...this._routes.values()], null, "\t");
|
||||
await lib.safeOutputFile(this._filePath, payload);
|
||||
this._dirty = false;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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__"
|
||||
},
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+9
-2
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
+136
-18
@@ -12,6 +12,7 @@ import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import type { HostConnector } from "../host";
|
||||
import FactorioProxy from "./FactorioProxy";
|
||||
import Instance from "./Instance";
|
||||
import InstanceConnection from "./InstanceConnection";
|
||||
import BaseHostPlugin from "./BaseHostPlugin";
|
||||
@@ -266,6 +267,7 @@ export default class Host extends lib.Link {
|
||||
instanceConnections = new Map<number, InstanceConnection>();
|
||||
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||
factorioProxies = new Map<number, FactorioProxy>();
|
||||
|
||||
adminlist = new Set<string>();
|
||||
banlist = new Map<string, string>();
|
||||
@@ -277,6 +279,7 @@ export default class Host extends lib.Link {
|
||||
_startup = true;
|
||||
_disconnecting = false;
|
||||
_shuttingDown = false;
|
||||
_proxyStartPromises = new Map<number, Promise<void>>();
|
||||
|
||||
static instanceConfigWarning = {
|
||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||
@@ -410,7 +413,7 @@ export default class Host extends lib.Link {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -690,16 +693,21 @@ export default class Host extends lib.Link {
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
instanceId,
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.gamePort
|
||||
: this.publicGamePort(instanceId, instanceInfo),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
: instanceInfo.config.get("factorio.version"),
|
||||
// TODO: factorio.version is not validated
|
||||
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
|
||||
)
|
||||
);
|
||||
|
||||
// Send the new list of saves for this assigned instance to the controller.
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
|
||||
await this._ensureFactorioProxy(instanceId, instanceInfo);
|
||||
|
||||
// save a copy of the instance config
|
||||
await instanceInfo.config.save();
|
||||
}
|
||||
@@ -714,6 +722,10 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
this.instanceInfos.delete(instanceId);
|
||||
if (this.factorioProxies.has(instanceId)) {
|
||||
await this.factorioProxies.get(instanceId)!.stop();
|
||||
this.factorioProxies.delete(instanceId);
|
||||
}
|
||||
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||
}
|
||||
}
|
||||
@@ -734,6 +746,23 @@ export default class Host extends lib.Link {
|
||||
return instanceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieved public port for clients to connect to for the given instance.
|
||||
* @param instanceId - ID of the instance to get the port for.
|
||||
* @returns Game port for the instance.
|
||||
*/
|
||||
publicGamePort(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
|
||||
instanceInfo = instanceInfo ?? this.discoveredInstanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
instanceInfo.config.get("factorio.game_port")
|
||||
?? instanceInfo.config.get("factorio.host_assigned_game_port")
|
||||
?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieved assigned port of the given instance
|
||||
* @param instanceId - ID of the instance to get the assigned game port of:
|
||||
@@ -751,6 +780,18 @@ export default class Host extends lib.Link {
|
||||
);
|
||||
}
|
||||
|
||||
ensureGamePortAssigned(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) {
|
||||
const configuredPort = instanceInfo.config.get("factorio.game_port");
|
||||
const assignedPort = instanceInfo.config.get("factorio.host_assigned_game_port");
|
||||
if (configuredPort !== null) {
|
||||
return configuredPort;
|
||||
}
|
||||
if (assignedPort !== null) {
|
||||
return assignedPort;
|
||||
}
|
||||
return this.assignGamePort(instanceId);
|
||||
}
|
||||
|
||||
assignGamePort(instanceId: number) {
|
||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||
for (const [id, instance] of this.instanceInfos) {
|
||||
@@ -778,6 +819,42 @@ export default class Host extends lib.Link {
|
||||
return newPort;
|
||||
}
|
||||
|
||||
async startInstanceFromProxy(instanceId: number) {
|
||||
const existing = this._proxyStartPromises.get(instanceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (this._shuttingDown || this.recoveryMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
if (instanceConnection && instanceConnection.instance.status === "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instanceConnection) {
|
||||
instanceConnection = await this._connectInstance(instanceId);
|
||||
}
|
||||
|
||||
if (instanceConnection.instance.status !== "running") {
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.error(
|
||||
`Error starting instance from proxy:\n${err.stack ?? err.message ?? err}`,
|
||||
this.instanceLogMeta(instanceId)
|
||||
);
|
||||
}).finally(() => {
|
||||
this._proxyStartPromises.delete(instanceId);
|
||||
});
|
||||
|
||||
this._proxyStartPromises.set(instanceId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect an unloaded instance
|
||||
*
|
||||
@@ -805,19 +882,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 +1172,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 +1193,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`),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1116,7 +1222,9 @@ export default class Host extends lib.Link {
|
||||
list.push(new lib.HostInstanceUpdate(
|
||||
instanceInfo.config.toRemote("controller"),
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.gamePort
|
||||
: this.publicGamePort(instanceId, instanceInfo),
|
||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||
));
|
||||
}
|
||||
@@ -1144,6 +1252,7 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async prepareDisconnect() {
|
||||
@@ -1174,6 +1283,15 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
for (const proxy of this.factorioProxies.values()) {
|
||||
try {
|
||||
await proxy.stop();
|
||||
} catch (err: any) {
|
||||
logger.error(`Unexpected error stopping proxy:\n${err.stack ?? err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
this.factorioProxies.clear();
|
||||
|
||||
try {
|
||||
await this.connector.disconnect();
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -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,
|
||||
@@ -397,6 +398,13 @@ export default class Instance extends lib.Link {
|
||||
stats,
|
||||
};
|
||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||
const hostId = this.config.get("instance.assigned_host");
|
||||
if (hostId !== null) {
|
||||
this.sendTo(
|
||||
"controller",
|
||||
new lib.InstancePlayerRouteUpdateEvent(name, this.id, hostId, this.server.gamePort, Date.now()),
|
||||
);
|
||||
}
|
||||
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
||||
}
|
||||
|
||||
@@ -547,13 +555,15 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
notifyStatus(status: Instance["_status"]) {
|
||||
this._status = status;
|
||||
const publicPort = this.server.gamePort;
|
||||
this.sendTo(
|
||||
"controller",
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
this.id,
|
||||
status,
|
||||
this.server.gamePort,
|
||||
status === "running"? this.server.version : this.config.get("factorio.version"),
|
||||
publicPort,
|
||||
// TODO: factorio.version is not validated
|
||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -651,7 +661,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 +970,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();
|
||||
}
|
||||
|
||||
+55
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1353,7 +1363,40 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (process.platform === "darwin") {
|
||||
return this.dataPath("..", "MacOS", "factorio");
|
||||
}
|
||||
return this.dataPath("..", "bin", "x64", "factorio");
|
||||
const binDir = this.dataPath("..", "bin", "x64");
|
||||
const runPath = path.join(binDir, "factorio-run");
|
||||
if (fs.existsSync(runPath)) {
|
||||
return runPath;
|
||||
}
|
||||
const runPathExe = `${runPath}.exe`;
|
||||
if (fs.existsSync(runPathExe)) {
|
||||
return runPathExe;
|
||||
}
|
||||
const factorioPath = path.join(binDir, "factorio");
|
||||
if (fs.existsSync(factorioPath)) {
|
||||
return factorioPath;
|
||||
}
|
||||
const factorioPathExe = `${factorioPath}.exe`;
|
||||
if (fs.existsSync(factorioPathExe)) {
|
||||
return factorioPathExe;
|
||||
}
|
||||
const directRunPath = path.join(this._factorioDir, "factorio-run");
|
||||
if (fs.existsSync(directRunPath)) {
|
||||
return directRunPath;
|
||||
}
|
||||
const directRunPathExe = `${directRunPath}.exe`;
|
||||
if (fs.existsSync(directRunPathExe)) {
|
||||
return directRunPathExe;
|
||||
}
|
||||
const directFactorioPath = path.join(this._factorioDir, "factorio");
|
||||
if (fs.existsSync(directFactorioPath)) {
|
||||
return directFactorioPath;
|
||||
}
|
||||
const directFactorioPathExe = `${directFactorioPath}.exe`;
|
||||
if (fs.existsSync(directFactorioPathExe)) {
|
||||
return directFactorioPathExe;
|
||||
}
|
||||
return factorioPath;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export interface ControllerConfigFields {
|
||||
"controller.metrics_timeout": number;
|
||||
"controller.system_metrics_interval": number;
|
||||
"controller.proxy_stream_timeout": number;
|
||||
"controller.player_proxy_port": number | null;
|
||||
"controller.player_proxy_default_instance_id": number | null;
|
||||
"controller.factorio_username": string | null;
|
||||
"controller.factorio_token": string | null;
|
||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||
@@ -35,6 +37,7 @@ export interface ControllerConfigFields {
|
||||
"controller.allow_remote_updates": boolean;
|
||||
"controller.allow_plugin_updates": boolean;
|
||||
"controller.allow_plugin_install": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +178,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
type: "number",
|
||||
initialValue: 15,
|
||||
},
|
||||
"controller.player_proxy_port": {
|
||||
title: "Player Proxy Port",
|
||||
description: "UDP port to accept player connections for the controller proxy, set to null to disable.",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
initialValue: 34197,
|
||||
},
|
||||
"controller.player_proxy_default_instance_id": {
|
||||
title: "Player Proxy Default Instance",
|
||||
description: "Instance ID to route players to when no routing history is available.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
"controller.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
description: "Username to authenticate with Factorio API with.",
|
||||
@@ -267,6 +284,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,11 +417,13 @@ 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;
|
||||
"factorio.shutdown_timeout": number;
|
||||
"factorio.game_port": number | null;
|
||||
"factorio.proxy_port": number | null;
|
||||
"factorio.host_assigned_game_port": number | null;
|
||||
"factorio.rcon_port": number | null;
|
||||
"factorio.rcon_password": string | null;
|
||||
@@ -476,6 +496,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
@@ -499,6 +520,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
"factorio.proxy_port": {
|
||||
description:
|
||||
"UDP port to accept client connections on. When set, the host will proxy traffic to " +
|
||||
"the instance game port and start the instance on first packet.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
"factorio.host_assigned_game_port": {
|
||||
access: ["host"],
|
||||
type: "number",
|
||||
|
||||
@@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import * as libHash from "../hash";
|
||||
import * as libSchema from "../schema";
|
||||
import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
|
||||
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
|
||||
};
|
||||
|
||||
const lettersRegex = /^[a-zA-Z]+$/;
|
||||
|
||||
export class ModDependency {
|
||||
public type: ModDependencyType;
|
||||
public name: string;
|
||||
public version: ModVersionEquality | undefined;
|
||||
|
||||
static getTypeFromPrefix(prefix: string): ModDependencyType {
|
||||
switch (prefix) {
|
||||
case "!":
|
||||
return "incompatible";
|
||||
case "?":
|
||||
return "optional";
|
||||
case "(?)":
|
||||
return "hidden";
|
||||
case "~":
|
||||
return "unordered";
|
||||
case "":
|
||||
return "required";
|
||||
default:
|
||||
throw new Error(`Invalid dependency prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
public specification: string,
|
||||
) {
|
||||
const parts = specification.split(" ");
|
||||
|
||||
// Fix mods which include spaces in their name, joins consecutive words together
|
||||
let index = 0;
|
||||
while (index < parts.length - 1) {
|
||||
const current = parts[index];
|
||||
const next = parts[index + 1];
|
||||
if (lettersRegex.test(current) && lettersRegex.test(next)) {
|
||||
parts[index] = `${current} ${next}`;
|
||||
parts.splice(index + 1, 1);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the dependency specification
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
break;
|
||||
case 2:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
break;
|
||||
case 3:
|
||||
this.type = "required";
|
||||
this.name = parts[0];
|
||||
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
|
||||
break;
|
||||
case 4:
|
||||
this.type = ModDependency.getTypeFromPrefix(parts[0]);
|
||||
this.name = parts[1];
|
||||
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid dependency specification: ${specification}`);
|
||||
}
|
||||
}
|
||||
|
||||
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
|
||||
const mod = mods.find(m => m.name === this.name);
|
||||
if (mod === undefined) {
|
||||
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
|
||||
} else if (this.type === "incompatible") {
|
||||
return "incompatible";
|
||||
} else if (this.version && !this.version.testVersion(mod.version)) {
|
||||
return "wrong_version";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isSatisfied(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.checkUnsatisfiedReason(mods) === undefined;
|
||||
}
|
||||
|
||||
get incompatible() {
|
||||
return this.type === "incompatible";
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.type === "unordered" || this.type === "required";
|
||||
}
|
||||
|
||||
get optional() {
|
||||
return this.type === "hidden" || this.type === "optional";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.String();
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.specification;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info about a mod available on the controller.
|
||||
@@ -35,13 +154,13 @@ export default class ModInfo {
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
version = "";
|
||||
version = "0.0.0" as FullVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerModVersion(this.version);
|
||||
return integerFullVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,21 +197,38 @@ export default class ModInfo {
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
factorioVersion = "0.12";
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependiences for this mod.
|
||||
* Dependencies for this mod.
|
||||
* Sourced from info.json.
|
||||
*/
|
||||
dependencies = ["base"];
|
||||
dependencies = [new ModDependency("base")];
|
||||
|
||||
/**
|
||||
* Dependencies for this mod.
|
||||
* As they would be represented in info.json.
|
||||
*/
|
||||
get dependencySpecifications() {
|
||||
return this.dependencies.map(d => d.specification);
|
||||
}
|
||||
|
||||
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
|
||||
return this.dependencies
|
||||
.map(d => d.checkUnsatisfiedReason(mods))
|
||||
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
|
||||
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
|
||||
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
|
||||
), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected name of zip file containing this mod.
|
||||
@@ -107,7 +243,7 @@ export default class ModInfo {
|
||||
* @param version - Mod's version
|
||||
* @returns string containing {name}_{version}.zip
|
||||
*/
|
||||
static filename(name: string, version: string) {
|
||||
static filename(name: string, version: FullVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -137,13 +273,13 @@ export default class ModInfo {
|
||||
// Content of info.json found in mod files
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -172,7 +308,7 @@ export default class ModInfo {
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
|
||||
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
|
||||
|
||||
// Additional data
|
||||
if (json.size) { modInfo.size = json.size; }
|
||||
@@ -195,8 +331,8 @@ export default class ModInfo {
|
||||
if (this.homepage) { json.homepage = this.homepage; }
|
||||
if (this.description) { json.description = this.description; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
|
||||
json.dependencies = this.dependencies;
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
if (this.size) { json.size = this.size; }
|
||||
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
|
||||
@@ -227,10 +363,6 @@ export default class ModInfo {
|
||||
throw new Error("Mod's info.json is not valid");
|
||||
}
|
||||
|
||||
if (!modVersionRegExp.test(modInfo.version)) {
|
||||
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(modPath);
|
||||
return this.fromJSON({
|
||||
...modInfo,
|
||||
|
||||
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import { integerFactorioVersion } from "./version";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
export const ModSettingColor = Type.Object({
|
||||
r: Type.Number(),
|
||||
@@ -40,17 +46,21 @@ export interface ModRecord {
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: string,
|
||||
version: FullVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
error?: "missing"|"bad_checksum",
|
||||
error?: "missing" | "bad_checksum",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
|
||||
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
|
||||
info?: ModInfo,
|
||||
}
|
||||
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -82,13 +92,13 @@ export default class ModPack {
|
||||
/**
|
||||
* Version of Factorio this mod pack is for
|
||||
*/
|
||||
factorioVersion = "1.1.0";
|
||||
factorioVersion = "1.1.0" as PartialVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerFactorioVersion(this.factorioVersion);
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +146,7 @@ export default class ModPack {
|
||||
"id": Type.Optional(Type.Integer()),
|
||||
"name": Type.String(),
|
||||
"description": Type.String(),
|
||||
"factorio_version": Type.String(),
|
||||
"factorio_version": PartialVersionSchema,
|
||||
"mods": Type.Array(ModRecordJsonSchema),
|
||||
"settings": Type.Object({
|
||||
"startup": ModSettingsJsonSchema,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>) {
|
||||
@@ -975,3 +976,30 @@ export class InstancePlayerUpdateEvent {
|
||||
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
||||
}
|
||||
}
|
||||
|
||||
export class InstancePlayerRouteUpdateEvent {
|
||||
declare ["constructor"]: typeof InstancePlayerRouteUpdateEvent;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public instanceId: number,
|
||||
public hostId: number,
|
||||
public gamePort: number,
|
||||
public timestampMs: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"instanceId": Type.Integer(),
|
||||
"hostId": Type.Integer(),
|
||||
"gamePort": Type.Integer(),
|
||||
"timestampMs": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.instanceId, json.hostId, json.gamePort, json.timestampMs);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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()];
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Library for interfacing with external apis
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
@@ -145,7 +145,7 @@ function readMapPosition(state: MapReaderState) {
|
||||
y = state.last_position.y + y_diff;
|
||||
}
|
||||
state.last_position.x = x;
|
||||
state.last_position.x = y;
|
||||
state.last_position.y = y;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.SystemInfoUpdateEvent,
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
@@ -92,6 +93,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAdminlistUpdateEvent,
|
||||
instance.InstanceWhitelistUpdateEvent,
|
||||
instance.InstancePlayerUpdateEvent,
|
||||
instance.InstancePlayerRouteUpdateEvent,
|
||||
|
||||
mod.ModPackGetRequest,
|
||||
mod.ModPackGetDefaultRequest,
|
||||
@@ -108,6 +110,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
mod.ModPackUpdatesEvent,
|
||||
mod.ModUpdatesEvent,
|
||||
mod.ModPortalDownloadRequest,
|
||||
mod.ModDependencyResolveRequest,
|
||||
|
||||
role.PermissionListRequest,
|
||||
role.RoleListRequest,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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": ".",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Descriptions, Grid, Typography } from "antd";
|
||||
import { Descriptions, Grid, Tooltip, Typography, Space } from "antd";
|
||||
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
|
||||
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
|
||||
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
|
||||
import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -8,11 +11,25 @@ const { useBreakpoint } = Grid;
|
||||
|
||||
type ModDetailsProps<T> = {
|
||||
mod: T;
|
||||
mods?: lib.ModRecord[];
|
||||
actions?: (mod: T) => React.JSX.Element;
|
||||
};
|
||||
|
||||
export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props: ModDetailsProps<T>) {
|
||||
let screens = useBreakpoint();
|
||||
const mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
|
||||
let mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
|
||||
|
||||
if ("info" in mod) {
|
||||
// Add the mod info if present in a mod record
|
||||
mod = { ...mod, ...mod.info };
|
||||
}
|
||||
|
||||
const depWarnings = new Map<string, lib.ModRecord["warning"]>();
|
||||
if (mod.dependencies && props.mods) {
|
||||
for (const dependency of mod.dependencies) {
|
||||
depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods));
|
||||
}
|
||||
}
|
||||
|
||||
return <Descriptions
|
||||
className="borderless"
|
||||
@@ -52,7 +69,14 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
{Boolean(mod.homepage) && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Internal Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
|
||||
{Boolean(mod.factorioVersion)
|
||||
&& <Descriptions.Item label="Factorio Version">{mod.factorioVersion}</Descriptions.Item>
|
||||
&& <Descriptions.Item label="Factorio Version">
|
||||
<Space>
|
||||
{mod.warning === "wrong_factorio_version" && <Tooltip title="Wrong factorio version.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{mod.factorioVersion}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
}
|
||||
{Boolean(mod.filename) && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
|
||||
{mod.size !== undefined
|
||||
@@ -62,8 +86,22 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
{mod.dependencies && mod.dependencies.length
|
||||
&& <Descriptions.Item label="Dependencies" span={2}>
|
||||
{mod.dependencies
|
||||
.filter(e => !e.startsWith("(?)"))
|
||||
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
|
||||
.filter(e => e.type !== "hidden")
|
||||
.map((e, i) => <Fragment key={i}>
|
||||
<Space>
|
||||
{depWarnings.get(e.name) === "incompatible" && <Tooltip title="Incompatible mod added.">
|
||||
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{depWarnings.get(e.name) === "missing_dependency" && <Tooltip title="Dependency missing.">
|
||||
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{depWarnings.get(e.name) === "wrong_version" && <Tooltip title="Wrong version added.">
|
||||
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
|
||||
</Tooltip>}
|
||||
{e.specification}
|
||||
</Space>
|
||||
<br/>
|
||||
</Fragment>)
|
||||
}
|
||||
</Descriptions.Item>
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig,
|
||||
TablePaginationConfig, Alert, notification,
|
||||
} from "antd";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined,
|
||||
CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
|
||||
import ExportOutlined from "@ant-design/icons/ExportOutlined";
|
||||
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
|
||||
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
|
||||
import CloseOutlined from "@ant-design/icons/CloseOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "./ControlContext";
|
||||
@@ -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}
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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/*"],
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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,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/*"],
|
||||
|
||||
@@ -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,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/*"],
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user