Date: Sat, 18 Oct 2025 18:27:41 +0200
Subject: [PATCH 12/46] Release hotfix of installer
---
packages/create/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/create/package.json b/packages/create/package.json
index 29883cfa..9297d19f 100644
--- a/packages/create/package.json
+++ b/packages/create/package.json
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
- "version": "2.0.0-alpha.21",
+ "version": "2.0.0-alpha.21b",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
From 93fa12200fee612af3ebba28bdf4b5f18d1f5880 Mon Sep 17 00:00:00 2001
From: Hornwitser
Date: Sat, 18 Oct 2025 18:43:55 +0200
Subject: [PATCH 13/46] Fix installer asking for remoteNpm even when given
---
packages/create/create.js | 1 +
packages/create/package.json | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/create/create.js b/packages/create/create.js
index 84d2ad2a..fe4318d7 100755
--- a/packages/create/create.js
+++ b/packages/create/create.js
@@ -689,6 +689,7 @@ async function inquirerMissingArgs(args) {
}
if (["standalone", "controller", "host"].includes(answers.mode)) {
+ if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
answers = await inquirer.prompt([
{
type: "confirm",
diff --git a/packages/create/package.json b/packages/create/package.json
index 9297d19f..32b5bb26 100644
--- a/packages/create/package.json
+++ b/packages/create/package.json
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
- "version": "2.0.0-alpha.21b",
+ "version": "2.0.0-alpha.21c",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
From 3fbd9a995d46e98e7ec904def3e7eefc8e5b1f03 Mon Sep 17 00:00:00 2001
From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Date: Sat, 1 Nov 2025 16:10:24 +0000
Subject: [PATCH 14/46] Add better plugin index missing error (#799)
* Add better plugin load errors
* Remove check for permission
* Account for packages using different main file
---
packages/lib/src/plugin_loader.ts | 10 ++++++++--
test/lib/plugin_loader.js | 7 ++++---
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts
index 45279946..115aef46 100644
--- a/packages/lib/src/plugin_loader.ts
+++ b/packages/lib/src/plugin_loader.ts
@@ -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) {
// 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;
}
diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js
index 308e3716..f8e275e7 100644
--- a/test/lib/plugin_loader.js
+++ b/test/lib/plugin_loader.js
@@ -5,7 +5,6 @@ const path = require("path");
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
-const { escapeRegExp } = lib;
describe("lib/plugin_loader", function() {
@@ -34,7 +33,10 @@ describe("lib/plugin_loader", function() {
});
it("should ignore missing plugins", async function() {
- const result = await lib.loadPluginInfos(new Map([["missing", missingPlugin]]), []);
+ const result = await lib.loadPluginInfos(new Map([
+ ["missing", path.resolve(missingPlugin)],
+ ["no_index", path.resolve(baseDir)],
+ ]), []);
assert.deepEqual(result, []);
});
it("should load test plugin", async function() {
@@ -46,7 +48,6 @@ describe("lib/plugin_loader", function() {
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
-
require(path.resolve(brokenPlugin));
} catch (err) {
brokenMessage = err.message;
From 41151d47f026719a776c9ff524a0d73711fc0bc1 Mon Sep 17 00:00:00 2001
From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Date: Sat, 1 Nov 2025 16:10:41 +0000
Subject: [PATCH 15/46] Add publish workflow (#798)
* Change build-mod to belong to packages
* Add publish script and workflow
* Fix typo in variable name
---
.github/workflows/publish.yml | 48 ++++++++++++
clean.js | 6 +-
package.json | 2 +-
packages/host/package.json | 1 +
packages/lib/build_mod.js | 16 +++-
publish.js | 134 ++++++++++++++++++++++++++++++++++
6 files changed, 201 insertions(+), 6 deletions(-)
create mode 100644 .github/workflows/publish.yml
create mode 100644 publish.js
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..c72afbb1
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,48 @@
+
+name: Publish
+
+on:
+ release:
+ types:
+ - released
+
+jobs:
+ npm:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 8
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ registry-url: https://registry.npmjs.org
+ - run: pnpm install --no-frozen-lockfile
+ - run: pnpm -r --filter='./{plugins,packages}/*' publish
+ env:
+ NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
+ - uses: softprops/action-gh-release@v2
+ with:
+ files: pnpm-lock.yaml
+
+ factorio:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 8
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ - run: pnpm install --no-frozen-lockfile
+ - run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
+ - run: node ./publish.js factorio ./dist
+ env:
+ FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
+ - uses: softprops/action-gh-release@v2
+ with:
+ files: ./dist/*
diff --git a/clean.js b/clean.js
index 60d4aa04..8e15dcba 100644
--- a/clean.js
+++ b/clean.js
@@ -86,8 +86,8 @@ async function removeExecutionArtifacts() {
async function main() {
await yargs(process.argv.slice(2))
.scriptName("clean")
- .usage("$0 [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();
}
diff --git a/package.json b/package.json
index 068ef25e..11d5cdbb 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
- "build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
+ "build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
diff --git a/packages/host/package.json b/packages/host/package.json
index 2808b282..15ee76f9 100644
--- a/packages/host/package.json
+++ b/packages/host/package.json
@@ -7,6 +7,7 @@
"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": {
diff --git a/packages/lib/build_mod.js b/packages/lib/build_mod.js
index eee00d8d..30cc4326 100644
--- a/packages/lib/build_mod.js
+++ b/packages/lib/build_mod.js
@@ -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
diff --git a/publish.js b/publish.js
new file mode 100644
index 00000000..447a1610
--- /dev/null
+++ b/publish.js
@@ -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 ", "$0 "], "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();
+}
From 73cc019d2f2b49db753498ef64037176be9851d8 Mon Sep 17 00:00:00 2001
From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Date: Sat, 1 Nov 2025 16:11:11 +0000
Subject: [PATCH 16/46] Sets restart required when a plugin is updated (#794)
* Template type for load plugin config
* Add plugin update checking
* Add restart icon to instance list
* Add tests
* Log package name when failing to read package json
---
packages/controller/src/ControlConnection.ts | 4 +-
packages/controller/src/Controller.ts | 55 ++++++++++++-----
packages/controller/src/routes.ts | 4 +-
packages/host/src/Host.ts | 55 ++++++++++++-----
packages/host/src/Instance.ts | 2 +-
packages/lib/src/config/definitions.ts | 3 +
.../web_ui/src/components/ControllerPage.tsx | 7 ++-
.../web_ui/src/components/HostViewPage.tsx | 7 ++-
.../web_ui/src/components/InstanceList.tsx | 20 +++---
test/controller/Controller.js | 61 ++++++++++++++++++-
test/host.js | 59 ++++++++++++++++++
11 files changed, 228 insertions(+), 49 deletions(-)
diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts
index 8b6f4f28..c54a3c45 100644
--- a/packages/controller/src/ControlConnection.ts
+++ b/packages/controller/src/ControlConnection.ts
@@ -1079,9 +1079,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`),
));
}
diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts
index 5d2b8f07..e3d13d02 100644
--- a/packages/controller/src/Controller.ts
+++ b/packages/controller/src/Controller.ts
@@ -549,19 +549,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 +560,44 @@ export default class Controller {
}
}
+ async checkRestartRequired() {
+ if (this.config.restartRequired) {
+ return true;
+ }
+
+ let packageName = "@clusterio/controller";
+ try {
+ // First check the clusterio version
+ const runningVersion = this.config.get("controller.version");
+ const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
+ if (runningVersion !== packageJson.version) {
+ this.config.restartRequired = true;
+ return true;
+ }
+
+ // Second check plugin versions
+ for (const pluginInfo of this.pluginInfos) {
+ if (
+ !pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
+ || !this.config.get(`${pluginInfo.name}.load_plugin`)
+ ) {
+ continue;
+ }
+
+ packageName = pluginInfo.npmPackage ?? pluginInfo.name;
+ const pluginPackageJson = await fs.readJSON(path.join(pluginInfo.requirePath, "package.json"));
+ if (pluginInfo.version !== pluginPackageJson.version) {
+ this.config.restartRequired = true;
+ return true;
+ }
+ }
+ } catch (err: any) {
+ logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
+ }
+
+ return false;
+ }
+
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -1127,7 +1154,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;
}
diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts
index 303059d4..5826f37c 100644
--- a/packages/controller/src/routes.ts
+++ b/packages/controller/src/routes.ts
@@ -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.
diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts
index 839fac5d..ae4bd37b 100644
--- a/packages/host/src/Host.ts
+++ b/packages/host/src/Host.ts
@@ -410,7 +410,7 @@ export default class Host extends lib.Link {
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
- || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
+ || !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -805,19 +805,46 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
- if (!this.config.restartRequired) {
- // If a restart isn't already required, then test if a new version is installed
- try {
- const runningVersion = this.config.get("host.version");
- const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
- if (runningVersion !== packageJson.version) {
- this.config.restartRequired = true;
- }
- } catch (err: any) {
- logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
- }
+ const restartRequired = await this.checkRestartRequired();
+ return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
+ }
+
+ async checkRestartRequired() {
+ if (this.config.restartRequired) {
+ return true;
}
- return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
+
+ let packageName = "@clusterio/host";
+ try {
+ // First check the clusterio version
+ const runningVersion = this.config.get("host.version");
+ const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
+ if (runningVersion !== packageJson.version) {
+ this.config.restartRequired = true;
+ return true;
+ }
+
+ // Second check plugin versions
+ for (const pluginInfo of this.pluginInfos) {
+ if (
+ !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
+ || !this.config.get(`${pluginInfo.name}.load_plugin`)
+ ) {
+ continue;
+ }
+
+ packageName = pluginInfo.npmPackage ?? pluginInfo.name;
+ const pluginPackageJson = await fs.readJSON(path.join(pluginInfo.requirePath, "package.json"));
+ if (pluginInfo.version !== pluginPackageJson.version) {
+ this.config.restartRequired = true;
+ return true;
+ }
+ }
+ } catch (err: any) {
+ logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
+ }
+
+ return false;
}
async handleHostMetricsRequest() {
@@ -1087,7 +1114,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`),
));
}
diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts
index 0a47c0b2..26815bbf 100644
--- a/packages/host/src/Instance.ts
+++ b/packages/host/src/Instance.ts
@@ -651,7 +651,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;
}
diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts
index 138220fe..b948a04c 100644
--- a/packages/lib/src/config/definitions.ts
+++ b/packages/lib/src/config/definitions.ts
@@ -35,6 +35,7 @@ export interface ControllerConfigFields {
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
+ [key: `${string}.load_plugin`]: boolean;
}
/**
@@ -267,6 +268,7 @@ export interface HostConfigFields {
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
+ [key: `${string}.load_plugin`]: boolean;
}
/**
@@ -399,6 +401,7 @@ export interface InstanceConfigFields {
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
+ [key: `${string}.load_plugin`]: boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
diff --git a/packages/web_ui/src/components/ControllerPage.tsx b/packages/web_ui/src/components/ControllerPage.tsx
index 5284a5e6..4a65b415 100644
--- a/packages/web_ui/src/components/ControllerPage.tsx
+++ b/packages/web_ui/src/components/ControllerPage.tsx
@@ -34,9 +34,10 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont
actions.push({
key: "restart",
label: (
-
- Restart {restartRequired ? : undefined}
-
+ !restartRequired ? "Restart" :
+
+ Restart
+
),
onClick: () => {
control.send(
diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx
index d05115fb..78450966 100644
--- a/packages/web_ui/src/components/HostViewPage.tsx
+++ b/packages/web_ui/src/components/HostViewPage.tsx
@@ -37,9 +37,10 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
actions.push({
key: "restart",
label: (
-
- Restart {restartRequired ? : undefined}
-
+ !restartRequired ? "Restart" :
+
+ Restart
+
),
onClick: () => {
control.sendTo(
diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx
index fe299f00..4e756b84 100644
--- a/packages/web_ui/src/components/InstanceList.tsx
+++ b/packages/web_ui/src/components/InstanceList.tsx
@@ -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) {
@@ -50,12 +53,15 @@ export default function InstanceList(props: InstanceListProps) {
{
title: "Assigned Host",
key: "assignedHost",
- render: (_, instance) => e.stopPropagation()}
- >
- {hostName(instance.assignedHost)}
- ,
+ render: (_, instance) =>
+ e.stopPropagation()}
+ >
+ {hostName(instance.assignedHost)}
+
+
+ ,
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
responsive: ["sm"],
},
diff --git a/test/controller/Controller.js b/test/controller/Controller.js
index f3d7e7ab..aa3a82d7 100644
--- a/test/controller/Controller.js
+++ b/test/controller/Controller.js
@@ -1,4 +1,5 @@
"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");
@@ -27,8 +28,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 +236,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() {
diff --git a/test/host.js b/test/host.js
index 54fd8ffe..fe50b04d 100644
--- a/test/host.js
+++ b/test/host.js
@@ -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);
+ });
+ });
});
});
From d52931a32679a593533a174c690c37bf5346573e Mon Sep 17 00:00:00 2001
From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Date: Fri, 7 Nov 2025 19:22:17 +0000
Subject: [PATCH 17/46] Better handling of mod dependencies (#801)
* Add ModDependency and version types
* Add warnings for mod dependencies
* Add warning for wrong factorio version
* Add fix issues button
* Add automatic dependency resolution and download
* Add abilty to check for updates
* Add extra test coverage
* One extra test
* Fix copy paste error
---
packages/controller/src/BaseConnection.ts | 2 +-
packages/controller/src/ControlConnection.ts | 210 +++++--
packages/controller/src/Controller.ts | 14 +-
packages/controller/src/InstanceInfo.ts | 4 +-
packages/ctl/src/commands.ts | 21 +-
packages/host/src/Host.ts | 3 +-
packages/host/src/Instance.ts | 6 +-
packages/host/src/server.ts | 10 +-
packages/lib/src/ModStore.ts | 168 +++---
packages/lib/src/data/ModInfo.ts | 166 +++++-
packages/lib/src/data/ModPack.ts | 45 +-
packages/lib/src/data/index.ts | 2 +-
packages/lib/src/data/messages_instance.ts | 15 +-
packages/lib/src/data/messages_mod.ts | 140 ++++-
packages/lib/src/data/version.ts | 240 +++++++-
packages/lib/src/link/messages.ts | 1 +
.../web_ui/src/components/InstanceList.tsx | 4 +-
packages/web_ui/src/components/ModDetails.tsx | 48 +-
.../web_ui/src/components/ModPackViewPage.tsx | 495 +++++++++++++++--
packages/web_ui/src/components/ModsPage.tsx | 28 +-
test/common.js | 35 ++
test/controller/Controller.js | 48 +-
test/integration/clusterio.js | 2 +-
test/integration/index.js | 15 +-
test/lib/ModStore.test.js | 210 +++----
test/lib/data/ModInfo.js | 247 ++++++++-
test/lib/data/ModPack.js | 10 +-
test/lib/data/version.test.js | 515 ++++++++++++++++++
test/messages/mod.test.js | 462 ++++++++++++++++
29 files changed, 2763 insertions(+), 403 deletions(-)
create mode 100644 test/common.js
create mode 100644 test/lib/data/version.test.js
create mode 100644 test/messages/mod.test.js
diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts
index bda042aa..8efbf14c 100644
--- a/packages/controller/src/BaseConnection.ts
+++ b/packages/controller/src/BaseConnection.ts
@@ -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) {
diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts
index c54a3c45..b9f70cb1 100644
--- a/packages/controller/src/ControlConnection.ts
+++ b/packages/controller/src/ControlConnection.ts
@@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
+ this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
+ this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -124,7 +126,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
- this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -564,52 +565,181 @@ export default class ControlConnection extends BaseConnection {
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
- async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise {
- 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["mods"][number];
+ type ModPortalReleaseType = NonNullable[number];
+ const dependencyRequirements = new Map();
+ const optionalRequirements = new Map();
+ const candidateReleases = new Map();
+ const incompatible = new Set();
+ const invalid = new Set();
+
+ const _modPortalReleases = new Map();
+ 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((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((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) {
diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts
index e3d13d02..0fa27628 100644
--- a/packages/controller/src/Controller.ts
+++ b/packages/controller/src/Controller.ts
@@ -135,7 +135,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(
@@ -700,6 +701,17 @@ export default class Controller {
return instance;
}
+ static migrateModPacks(rawJson: unknown[]): Static[] {
+ const serialized = rawJson as Static[];
+ 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[] {
const serialized = rawJson as Static[];
return serialized.map(json => {
diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts
index 36959ee6..05d63596 100644
--- a/packages/controller/src/InstanceInfo.ts
+++ b/packages/controller/src/InstanceInfo.ts
@@ -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, location: lib.ConfigLocation) {
diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts
index 59be29b0..142e26d0 100644
--- a/packages/ctl/src/commands.ts
+++ b/packages/ctl/src/commands.ts
@@ -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));
},
}));
diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts
index ae4bd37b..c7409990 100644
--- a/packages/host/src/Host.ts
+++ b/packages/host/src/Host.ts
@@ -693,7 +693,8 @@ export default class Host extends lib.Link {
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
- : instanceInfo.config.get("factorio.version"),
+ // TODO: factorio.version is not validated
+ : instanceInfo.config.get("factorio.version") as lib.TargetVersion,
)
);
diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts
index 26815bbf..d3480c46 100644
--- a/packages/host/src/Instance.ts
+++ b/packages/host/src/Instance.ts
@@ -210,7 +210,8 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
- version: this.config.get("factorio.version"),
+ // TODO: factorio.version is not validated
+ version: this.config.get("factorio.version") as lib.TargetVersion,
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
@@ -553,7 +554,8 @@ end`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
- status === "running"? this.server.version : this.config.get("factorio.version"),
+ // TODO: factorio.version is not validated
+ status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
);
}
diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts
index 479037c9..4ffda317 100644
--- a/packages/host/src/server.ts
+++ b/packages/host/src/server.ts
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
* @returns Array with path to data dir and version found.
* @internal
*/
-async function findVersion(factorioDir: string, targetVersion: string) {
+async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
@@ -423,7 +423,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
- version?: string,
+ version?: lib.TargetVersion,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -523,7 +523,7 @@ export class FactorioServer extends events.EventEmitter {
_writeDir: string;
// Resolved in init
- _version: string | null = null;
+ _version: lib.FullVersion | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
@@ -532,7 +532,7 @@ export class FactorioServer extends events.EventEmitter {
_whitelist = new Set();
_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 +866,7 @@ export class FactorioServer extends events.EventEmitter {
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
- get version(): string | undefined {
+ get version(): lib.FullVersion | undefined {
return this._version ?? undefined;
}
diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts
index 84d03154..13a94be6 100644
--- a/packages/lib/src/ModStore.ts
+++ b/packages/lib/src/ModStore.ts
@@ -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 {
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 {
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 {
return new this(modsDirectory, files);
}
- private async downloadModFromReleases(releases: Array>, 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>, 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 {
}
}
+ /**
+ * 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, 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[] = [];
- 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[] = [];
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 {
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];
+ }
}
diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts
index e29db8d3..df97831a 100644
--- a/packages/lib/src/data/ModInfo.ts
+++ b/packages/lib/src/data/ModInfo.ts
@@ -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 = {
+ "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) {
+ 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((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,
diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts
index 74030b49..df3adf7d 100644
--- a/packages/lib/src/data/ModPack.ts
+++ b/packages/lib/src/data/ModPack.ts
@@ -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);
}
}
diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts
index cacc1211..5adf08e6 100644
--- a/packages/lib/src/data/index.ts
+++ b/packages/lib/src/data/index.ts
@@ -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";
diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts
index 08119562..a0a251d2 100644
--- a/packages/lib/src/data/messages_instance.ts
+++ b/packages/lib/src/data/messages_instance.ts
@@ -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,
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) {
@@ -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) {
diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts
index b5a4d17a..dc147b3d 100644
--- a/packages/lib/src/data/messages_mod.ts
+++ b/packages/lib/src/data/messages_mod.ts
@@ -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,
+ 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) {
@@ -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) {
- 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) {
+ 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) {
+ return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
+ }
+ };
+}
diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts
index bd79bece..882992b2 100644
--- a/packages/lib/src/data/version.ts
+++ b/packages/lib/src/data/version.ts
@@ -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;
+
+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;
+
+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(
+ 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(
+ 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) {
+ 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");
+ }
+ }
+}
diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts
index 65505bac..6e29a75f 100644
--- a/packages/lib/src/link/messages.ts
+++ b/packages/lib/src/link/messages.ts
@@ -108,6 +108,7 @@ export const dataClasses: (RequestClass | EventClass)
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
+ mod.ModDependencyResolveRequest,
role.PermissionListRequest,
role.RoleListRequest,
diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx
index 4e756b84..46b75f9d 100644
--- a/packages/web_ui/src/components/InstanceList.tsx
+++ b/packages/web_ui/src/components/InstanceList.tsx
@@ -37,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 = [
diff --git a/packages/web_ui/src/components/ModDetails.tsx b/packages/web_ui/src/components/ModDetails.tsx
index 973480fb..1403bf6c 100644
--- a/packages/web_ui/src/components/ModDetails.tsx
+++ b/packages/web_ui/src/components/ModDetails.tsx
@@ -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 = {
mod: T;
+ mods?: lib.ModRecord[];
actions?: (mod: T) => React.JSX.Element;
};
+
export default function ModDetails(props: ModDetailsProps) {
let screens = useBreakpoint();
- const mod: Partial & Partial = props.mod;
+ let mod: Partial & Partial = props.mod;
+
+ if ("info" in mod) {
+ // Add the mod info if present in a mod record
+ mod = { ...mod, ...mod.info };
+ }
+
+ const depWarnings = new Map();
+ if (mod.dependencies && props.mods) {
+ for (const dependency of mod.dependencies) {
+ depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods));
+ }
+ }
return (props:
{Boolean(mod.homepage) && {mod.homepage} }
{mod.name}
{Boolean(mod.factorioVersion)
- && {mod.factorioVersion}
+ &&
+
+ {mod.warning === "wrong_factorio_version" &&
+ {" "}
+ }
+ {mod.factorioVersion}
+
+
}
{Boolean(mod.filename) && {mod.filename} }
{mod.size !== undefined
@@ -62,8 +86,22 @@ export default function ModDetails(props:
{mod.dependencies && mod.dependencies.length
&&
{mod.dependencies
- .filter(e => !e.startsWith("(?)"))
- .map((e, i) => {e}
)
+ .filter(e => e.type !== "hidden")
+ .map((e, i) =>
+
+ {depWarnings.get(e.name) === "incompatible" &&
+ {" "}
+ }
+ {depWarnings.get(e.name) === "missing_dependency" &&
+ {" "}
+ }
+ {depWarnings.get(e.name) === "wrong_version" &&
+ {" "}
+ }
+ {e.specification}
+
+
+ )
}
}
diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx
index fd41acb2..bc60f4c7 100644
--- a/packages/web_ui/src/components/ModPackViewPage.tsx
+++ b/packages/web_ui/src/components/ModPackViewPage.tsx
@@ -1,19 +1,19 @@
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
+import { FieldData } from "rc-field-form/lib/interface";
+
import {
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
- TablePaginationConfig,
+ TablePaginationConfig, Alert, notification,
} from "antd";
-import { FieldData } from "rc-field-form/lib/interface";
+
+import {
+ ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined,
+ CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined,
+} from "@ant-design/icons";
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
-import ExportOutlined from "@ant-design/icons/ExportOutlined";
-import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
-import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
-import CloseOutlined from "@ant-design/icons/CloseOutlined";
-import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
-import PlusOutlined from "@ant-design/icons/PlusOutlined";
import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
@@ -28,16 +28,20 @@ import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
const { logger } = lib;
-const { Paragraph, Text } = Typography;
+const { Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
-
type ModChange =
{
- type: "name" | "description" | "factorioVersion",
+ type: "name" | "description"
name?: never,
scope?: never,
value: string,
+ } | {
+ type: "factorioVersion",
+ name?: never,
+ scope?: never,
+ value: lib.PartialVersion,
} | {
type: "settings.set" | "settings.delete",
name: string,
@@ -47,7 +51,7 @@ type ModChange =
type: "mods.set" | "mods.delete",
name: string,
scope?: never,
- value: lib.ModRecord | lib.ModInfo,
+ value: lib.ModRecord,
}
;
@@ -71,28 +75,38 @@ function SearchModsTable(props: SearchModsTableProps) {
const [modResultPageSize, setModResultPageSize] = useState(10);
const [modResultCount, setModResultCount] = useState(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState