From 00cf0f59ec00c4f312708ff89c955cf1b15c6089 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:36:01 +0000 Subject: [PATCH 01/16] Refuse to start controller with no users --- README.md | 2 +- packages/controller/controller.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1c54886..42e51dbb 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi ## Running Clusterio -After completing the installation start up the controller and at least one host separately. +After completing the installation and creating your admin user, start up the controller and at least one host separately. The installer provides the `run-controller` and `run-host` scripts to make this simple. Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in. diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index e4299534..c83b3d30 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -281,6 +281,7 @@ async function initialize(): Promise { type: "boolean", nargs: 0, default: false, describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected", }); + yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true }); yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 }); yargs.option("dev-plugin", { hidden: true, type: "array" }); }) @@ -417,6 +418,14 @@ async function startup() { ...await Controller.bootstrap(controllerConfig) ); + // Refuse to start if there are no users loaded + if (args.checkUserCount && controller.userManager.users.size === 0) { + logger.fatal( + "Cannot start controller, no users loaded.\n" + + "Try `npx clusteriocontroller bootstrap create-admin `"); + process.exit(1); + } + let secondSigint = false; process.on("SIGINT", () => { if (secondSigint) { From bccb6527a711c45d519a032ca1c634b455548272 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 7 Jan 2026 01:20:40 +0000 Subject: [PATCH 02/16] Add lock file for configs --- packages/controller/controller.ts | 14 ++- packages/ctl/ctl.ts | 3 +- packages/host/host.ts | 6 +- packages/lib/index.ts | 1 + packages/lib/src/LockFile.ts | 163 ++++++++++++++++++++++++++++ packages/lib/src/shared_commands.ts | 8 +- test/integration/clusterio.js | 41 ++++++- test/integration/index.js | 17 +-- test/lib/LockFile.test.js | 126 +++++++++++++++++++++ 9 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 packages/lib/src/LockFile.ts create mode 100644 test/lib/LockFile.test.js diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index c83b3d30..3eabb814 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -127,7 +127,8 @@ void new lib.Gauge( async function handleBootstrapCommand( args: any, - controllerConfig: lib.ControllerConfig + controllerConfig: lib.ControllerConfig, + controllerConfigLock: lib.LockFile, ): Promise { let subCommand = args._[1]; @@ -141,6 +142,8 @@ async function handleBootstrapCommand( await userManager.load(path.join(databaseDirectory, "users.json")); if (subCommand === "create-admin") { + await controllerConfigLock.acquire(); // Also needed to write to the database files + if (!args.name) { logger.error("name cannot be blank"); process.exitCode = 1; @@ -156,6 +159,7 @@ async function handleBootstrapCommand( admin.roleIds.add(adminRole.id); admin.isAdmin = true; await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json")); + await controllerConfigLock.release(); } else if (subCommand === "generate-user-token") { let user = userManager.getByName(args.name); @@ -353,6 +357,7 @@ async function initialize(): Promise { let controllerConfig; const controllerConfigPath = args.config; + const controllerConfigLock = new lib.LockFile(`${controllerConfigPath}.lock`); logger.info(`Loading config from ${controllerConfigPath}`); try { controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); @@ -378,10 +383,11 @@ async function initialize(): Promise { } if (command === "config") { - await lib.handleConfigCommand(args, controllerConfig); - + await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock); } else if (command === "bootstrap") { - await handleBootstrapCommand(args, controllerConfig); + await handleBootstrapCommand(args, controllerConfig, controllerConfigLock); + } else if (shouldRun) { + await controllerConfigLock.acquire(); // Hold the lock until process exit } return { diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index bafb43af..6d2816f1 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -236,6 +236,7 @@ export async function initialize( let controlConfig; const controlConfigPath = args.config; + const controlConfigLock = new lib.LockFile(`${controlConfigPath}.lock`); logger.verbose(`Loading config from ${controlConfigPath}`); try { controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath); @@ -257,7 +258,7 @@ export async function initialize( // Handle the control-config command before trying to connect. if (args._[0] === "control-config") { - await lib.handleConfigCommand(args, controlConfig); + await lib.handleConfigCommand(args, controlConfig, controlConfigLock); return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false }; } diff --git a/packages/host/host.ts b/packages/host/host.ts index 6a3b1a3f..259fb31c 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -169,6 +169,7 @@ async function startHost() { let hostConfig; const hostConfigPath = args.config; + const hostConfigLock = new lib.LockFile(`${hostConfigPath}.lock`); logger.info(`Loading config from ${hostConfigPath}`); try { hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath); @@ -186,10 +187,13 @@ async function startHost() { hostConfig.set("host.version", version); // Allows tracking last loaded version if (command === "config") { - await lib.handleConfigCommand(args, hostConfig); + await lib.handleConfigCommand(args, hostConfig, hostConfigLock); return; } + // From this point on the host should run + await hostConfigLock.acquire(); + // If we get here the command was run await fs.ensureDir(hostConfig.get("host.instances_directory")); await fs.ensureDir(hostConfig.get("host.mods_directory")); diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..204cc7d3 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -31,6 +31,7 @@ export * from "./src/subscriptions"; export * from "./src/datastore"; export * from "./src/load_plugin_list"; export * from "./src/rce_ops"; +export * from "./src/LockFile"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; diff --git a/packages/lib/src/LockFile.ts b/packages/lib/src/LockFile.ts new file mode 100644 index 00000000..5cb6ecce --- /dev/null +++ b/packages/lib/src/LockFile.ts @@ -0,0 +1,163 @@ +import { FileHandle, open, unlink, readFile } from "fs/promises"; +import { unlinkSync } from "fs"; +import assert from "assert"; + +/** + * Error thrown when the lock file already exists. + */ +export class LockFileExistsError extends Error { + constructor( + readonly filePath: string + ) { + super(`Cannot acquire lock, a valid lock file exists at ${filePath}`); + this.name = "LockFileExistsError"; + } +} + +/** + * Error thrown when the acquire or release called during an invalid state. + */ +export class LockFileBusyError extends Error { + constructor(message: string) { + super(message); + this.name = "LockFileBusyError"; + } +} + +type LockState = "idle" | "acquiring" | "acquired" | "releasing"; + +/** + * Cross-platform lock file utility for Node.js. + */ +export class LockFile { + private handle: FileHandle | null = null; + private state: LockState = "idle"; + + constructor( + private readonly filePath: string + ) {} + + /** + * Acquire the lock file. + * Throws LockFileExistsError if already locked. + * Throws LockFileBusyError if not in idle state. + */ + async acquire(): Promise { + if (this.state !== "idle") { + throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`); + } + + this.state = "acquiring"; + + if (await this.isStale()) { + await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed + } + + try { + this.handle = await open(this.filePath, "wx"); + await this.handle.write(`${process.pid}\n`); + this.attachExitHandlers(); + this.state = "acquired"; + } catch (err: any) { + this.state = "idle"; + if (err?.code === "EEXIST") { + throw new LockFileExistsError(this.filePath); + } + throw err; + } + } + + /** + * Release the lock file. + * Throws LockFileBusyError if not in acquired state. + */ + async release(): Promise { + if (this.state !== "acquired") { + throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`); + } + + this.state = "releasing"; + this.detachExitHandlers(); + + // Ignored: file may have already been removed by another process + try { await this.handle?.close(); } catch {} + try { await unlink(this.filePath); } catch {} + + this.handle = null; + this.state = "idle"; + } + + /** + * Check whether the lock is currently held by this process. + * True only if the lock is acquired and the PID in the file + * matches the current process. + */ + async isHeld(): Promise { + if (this.state !== "acquired") { + return false; + } + + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + return pidInLock === process.pid; + } catch { + // Ignored: file may not exist or be unreadable, so lock is not held + return false; + } + } + + /** + * Determine whether an existing lock is stale. + */ + async isStale(): Promise { + try { + const pidInLock = Number(await readFile(this.filePath, "utf8")); + if (!Number.isFinite(pidInLock)) { return true; } + // signal 0 is a special case to test if a process exists + process.kill(pidInLock, 0); + return false; + } catch { + return true; + } + } + + /** + * Attach process handlers, called when transitioning to "acquired" + */ + private attachExitHandlers(): void { + assert(this.state === "acquiring"); + process.on("SIGINT", this.handleExit); + process.on("SIGTERM", this.handleExit); + process.on("SIGHUP", this.handleExit); + process.on("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.on("exit", this.handleExitSync); + } + + /** + * Detach process handlers, called when transitioning to "idle" + */ + private async detachExitHandlers(): Promise { + assert(this.state === "releasing"); + process.off("SIGINT", this.handleExit); + process.off("SIGTERM", this.handleExit); + process.off("SIGHUP", this.handleExit); + process.off("uncaughtException", this.handleExit); + // eslint-disable-next-line node/no-sync + process.off("exit", this.handleExitSync); + } + + // Event handler for async signals / exceptions + private handleExit = async (): Promise => { + await this.release(); + }; + + // Event handler for synchronous process exit + private handleExitSync = (): void => { + try { + unlinkSync(this.filePath); + } catch (err) { + // Ignored: file may have already been removed by another process + } + }; +} diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index e65f925f..d9f6eb09 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -8,6 +8,7 @@ import * as libConfig from "./config"; import * as libFileOps from "./file_ops"; import { logger } from "./logging"; import * as libHelpers from "./helpers"; +import { LockFile } from "./LockFile"; function print(...content: any[]) { @@ -130,11 +131,12 @@ export function configCommand(yargs: any) { * * @param args - yargs args object. * @param instance - Config instance. - * @param configPath - Path to configuration file. + * @param lockFile - Lockfile required if attempting to write */ export async function handleConfigCommand( args: Record, instance: libConfig.Config, + lockFile: LockFile, ) { let command = (args._ as string[])[1]; @@ -155,6 +157,8 @@ export async function handleConfigCommand( } } else if (command === "set") { + await lockFile.acquire(); + if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); @@ -172,5 +176,7 @@ export async function handleConfigCommand( throw err; } } + + await lockFile.release(); } } diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 948dd556..8532b57a 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -13,7 +13,7 @@ const { wait } = lib; const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, - exec, execCtl, execCtlProcess, execHost, sendRcon, getControl, + execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, spawnNode, instancesDir, factorioDir, } = require("./index"); @@ -109,12 +109,31 @@ describe("Integration of Clusterio", function() { describe("clusteriocontroller", function() { describe("bootstrap generate-user-token", function() { it("work for existing user", async function() { - await exec("node ../../packages/controller bootstrap generate-user-token test"); + await execController("bootstrap generate-user-token test"); }); it("fails if user does not exist", async function() { await assert.rejects( - exec("node ../../packages/controller bootstrap generate-user-token invalid") + execController("bootstrap generate-user-token invalid") + ); + }); + }); + + describe("bootstrap create-admin", function() { + it("refuses to modify the database files", async function() { + await assert.rejects( + execController("bootstrap create-admin BootstrapAdminTest") + ); + }); + }); + + describe("config", function() { + it("can read the config file", async function() { + await execController("config list"); + }); + it("refuses to modify the config files", async function() { + await assert.rejects( + execController("confit set controller.name ConfigEditTest") ); }); }); @@ -143,6 +162,9 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); + it("refuses to start a second process", async function() { + await assert.rejects(execController("run")); + }); }); describe("queryLogRequestHandler", function() { @@ -245,6 +267,19 @@ describe("Integration of Clusterio", function() { await stopAltHost(hostProcess); } }); + describe("config", function() { + it("can read the config file", async function() { + await execHost("config list"); + }); + it("refuses to modify the config files", async function() { + await assert.rejects( + execHost("confit set host.name ConfigEditTest") + ); + }); + }); + it("refuses to start a second process", async function() { + await assert.rejects(execHost("run")); + }); }); describe("clusterioctl", function() { diff --git a/test/integration/index.js b/test/integration/index.js index 529b0c08..7d507040 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -173,26 +173,26 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json"); let hostConfigPath = path.join("temp", "test", "config-host.json"); let controlConfigPath = path.join("temp", "test", "config-control.json"); -async function exec(command, options = {}) { +function exec(command, options = {}) { // Uncomment to show commands run in tests // console.log(command); options = { cwd: path.join("temp", "test"), ...options }; - return await util.promisify(child_process.exec)(command, options); + return util.promisify(child_process.exec)(command, options); } -async function execController(...args) { +function execController(...args) { args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execHost(...args) { +function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`; - return await exec(...args); + return exec(...args); } -async function execCtlProcess(...args) { +function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`; - return await exec(...args); + return exec(...args); } let inExecCtl = false; @@ -394,6 +394,7 @@ module.exports = { TestControl, TestControlConnector, TestHostConnector, + execController, execCtlProcess, slowTest, get, diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js new file mode 100644 index 00000000..7034ecaf --- /dev/null +++ b/test/lib/LockFile.test.js @@ -0,0 +1,126 @@ +/* eslint-disable node/no-sync */ +"use strict"; +const assert = require("assert").strict; +const fs = require("fs-extra"); +const path = require("path"); + +const { LockFile } = require("@clusterio/lib"); + +const baseDir = path.join("temp", "test", "lock_file"); + +describe("class LockFile", function () { + /** @type {string} */ + let filePath; + /** @type {LockFile} */ + let lock; + + before(async function () { + await fs.ensureDir(baseDir); + }); + + beforeEach(function() { + filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`); + lock = new LockFile(filePath); + }); + + afterEach(async function () { + await fs.unlink(filePath).catch(() => {}); + }); + + describe("acquire()", function () { + it("should create the lock file when acquiring", async function () { + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not idle", async function () { + await lock.acquire(); + await assert.rejects(lock.acquire(), { name: "LockFileBusyError" }); + }); + + it("should remove stale lock file and acquire it", async function () { + await fs.writeFile(filePath, "999999\n"); // stale lock + await lock.acquire(); + assert.ok(fs.existsSync(filePath)); + }); + + it("should throw if existing lock file is not stale", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + await assert.rejects(lock.acquire(), { name: "LockFileExistsError" }); + }); + }); + + describe("release()", function () { + it("should remove the lock file when releasing", async function () { + await lock.acquire(); + await lock.release(); + assert.ok(!fs.existsSync(filePath)); + }); + + it("should throw LockFileBusyError if called when not acquired", async function () { + await assert.rejects(lock.release(), { name: "LockFileBusyError" }); + }); + }); + + describe("isHeld()", function () { + it("should return true only when lock file contains current PID", async function () { + await lock.acquire(); + const held = await lock.isHeld(); + assert.equal(held, true); + }); + + it("should return false when lock file does not exist", async function () { + const held = await lock.isHeld(); + assert.equal(held, false); + }); + + it("should return false if lock file contains different PID", async function () { + await fs.writeFile(filePath, "999999\n"); // simulate another process + const held = await lock.isHeld(); + assert.equal(held, false); + }); + }); + + describe("isStale()", function () { + it("should return true if lock file does not exist", async function () { + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for invalid PID in lock file", async function () { + await fs.writeFile(filePath, "abc\n"); // non-numeric PID + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return true for non-existent process PID", async function () { + await fs.writeFile(filePath, "999999\n"); // assume PID does not exist + const stale = await lock.isStale(); + assert.equal(stale, true); + }); + + it("should return false for current process PID", async function () { + await fs.writeFile(filePath, `${process.pid}\n`); + const stale = await lock.isStale(); + assert.equal(stale, false); + }); + }); + + describe("attach/detach exit handlers", function () { + it("should attach exit handler on acquire and detach on release", async function () { + await lock.acquire(); + const listenersBefore = process.listeners("exit").slice(); + assert.ok(listenersBefore.includes(lock.handleExitSync)); + + await lock.release(); + const listenersAfter = process.listeners("exit").slice(); + assert.ok(!listenersAfter.includes(lock.handleExitSync)); + }); + }); + + describe("handleExitSync()", function () { + it("should not throw if file already removed", function () { + assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file + }); + }); +}); From d48b731ea4269700fc7f99a983f843776038120a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:48:00 +0000 Subject: [PATCH 03/16] Add lockfile bypass --- packages/controller/controller.ts | 10 ++- packages/ctl/ctl.ts | 10 ++- packages/host/host.ts | 10 ++- test/integration/clusterio.js | 123 +++++++++++++++++++++++++++--- test/integration/index.js | 9 ++- test/lib/LockFile.test.js | 7 ++ 6 files changed, 148 insertions(+), 21 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 3eabb814..5e3a4838 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -255,6 +255,7 @@ async function initialize(): Promise { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Controller config", lib.configCommand) .command("bootstrap", "Bootstrap access to cluster", yargs => { @@ -355,9 +356,14 @@ async function initialize(): Promise { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let controllerConfig; const controllerConfigPath = args.config; - const controllerConfigLock = new lib.LockFile(`${controllerConfigPath}.lock`); + const controllerConfigLockPath = `${controllerConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controllerConfig; + const controllerConfigLock = new lib.LockFile(controllerConfigLockPath); logger.info(`Loading config from ${controllerConfigPath}`); try { controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 6d2816f1..793dd1ab 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -189,6 +189,7 @@ export async function initialize( default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("control-config", "Manage Control config", lib.configCommand) .wrap(parser.terminalWidth()) @@ -234,9 +235,14 @@ export async function initialize( .parse(argv) as CtlArguments ; - let controlConfig; const controlConfigPath = args.config; - const controlConfigLock = new lib.LockFile(`${controlConfigPath}.lock`); + const controlConfigLockPath = `${controlConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let controlConfig; + const controlConfigLock = new lib.LockFile(controlConfigLockPath); logger.verbose(`Loading config from ${controlConfigPath}`); try { controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath); diff --git a/packages/host/host.ts b/packages/host/host.ts index 259fb31c..c3f539a5 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -113,6 +113,7 @@ async function startHost() { default: "plugin-list.json", type: "string", }) + .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Host config", lib.configCommand) .command("run", "Run host", yargs => { @@ -167,9 +168,14 @@ async function startHost() { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let hostConfig; const hostConfigPath = args.config; - const hostConfigLock = new lib.LockFile(`${hostConfigPath}.lock`); + const hostConfigLockPath = `${hostConfigPath}.lock`; + if (args.bypassLockFile) { + await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist + } + + let hostConfig; + const hostConfigLock = new lib.LockFile(hostConfigLockPath); logger.info(`Loading config from ${hostConfigPath}`); try { hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 8532b57a..3fe1805f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -14,7 +14,7 @@ const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, - spawnNode, instancesDir, factorioDir, + spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, } = require("./index"); @@ -120,22 +120,46 @@ describe("Integration of Clusterio", function() { }); describe("bootstrap create-admin", function() { - it("refuses to modify the database files", async function() { + it("should refuse to modify the database files when locked", async function() { await assert.rejects( execController("bootstrap create-admin BootstrapAdminTest") ); }); + it("should modify the database files when locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(databaseDir, "users.json")); + assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true); + }); }); describe("config", function() { it("can read the config file", async function() { await execController("config list"); }); - it("refuses to modify the config files", async function() { + it("should refuse to modify the config file while locked", async function() { await assert.rejects( - execController("confit set controller.name ConfigEditTest") + execController("config set controller.name ConfigEditTest") ); }); + it("should modify the config file while locked if bypass option is given", async function() { + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("config set controller.name ConfigEditTest2 --bypass-lock-file"); + } finally { + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + } + + const json = await fs.readJSON(path.join(controllerConfigPath)); + assert.equal(json["controller.name"], "ConfigEditTest2"); + }); }); describe("run", function() { @@ -162,9 +186,38 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); - it("refuses to start a second process", async function() { + it("should refuse to start a second process while locked", async function() { await assert.rejects(execController("run")); }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + const lockFilePath = `${controllerConfigPath}.lock`; + await fs.copyFile(lockFilePath, "temp.lock"); + try { + await execController("run --bypass-lock-file"); + } catch (err) { + assert.equal(/Error: Server listening failed/.test(err.stderr), true); + } + await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted + }); + it("should refuse to start when no users are loaded", async function() { + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + await assert.rejects(execController("run", { cwd: dir })); + }); + it("should start when no users are loaded if bypass option given", async function() { + slowTest(this); + const dir = path.join("temp", "test", "empty_controller"); + await fs.emptyDir(dir); + const child = await spawnNode( + "altController", + "../../../packages/controller run --no-check-user-count", + /Started controller/, + { cwd: dir }, + ); + assert.ok(child.kill()); + assert.equal(child.killed, true); + }); }); describe("queryLogRequestHandler", function() { @@ -271,14 +324,62 @@ describe("Integration of Clusterio", function() { it("can read the config file", async function() { await execHost("config list"); }); - it("refuses to modify the config files", async function() { - await assert.rejects( - execHost("confit set host.name ConfigEditTest") - ); + it("should refuse to modify the config file while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} config set host.name ConfigEditTest`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should modify the config file while locked if bypass option is given", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`); + } finally { + await stopAltHost(hostProcess); + } + + const json = await fs.readJSON(path.join("temp", "test", config)); + assert.equal(json["host.name"], "ConfigEditTest2"); }); }); - it("refuses to start a second process", async function() { - await assert.rejects(execHost("run")); + describe("run", function() { + it("should refuse to start a second process while locked", async function() { + slowTest(this); + let hostProcess; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcess = await startAltHost(); + await assert.rejects( + execHost(`--config ${config} run`) + ); + } finally { + await stopAltHost(hostProcess); + } + }); + it("should start a second process while locked if bypass option is given", async function() { + slowTest(this); + let hostProcessA, hostProcessB; // Alt host used since the process could get killed + const config = "alt-host-config.json"; + try { + hostProcessA = await startAltHost(); + hostProcessB = await spawnNode( + "alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/ + ); + } finally { + await stopAltHost(hostProcessA); + await stopAltHost(hostProcessB); + } + }); }); }); diff --git a/test/integration/index.js b/test/integration/index.js index 7d507040..c47f18f7 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -229,7 +229,8 @@ function getControl() { return control; } -function spawn(name, cmd, waitFor) { +/** @returns {Promise} */ +function spawn(name, cmd, waitFor, options = {}) { const silent = process.env.SILENT_TEST; const bootstrap = !controllerProcess || !hostProcess; @@ -242,7 +243,7 @@ function spawn(name, cmd, waitFor) { return new Promise((resolve, reject) => { log(cmd); let parts = cmd.split(" "); - let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") }); + let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options }); let stdout = new LineSplitter({ readableObjectMode: true }); let stderr = new LineSplitter({ readableObjectMode: true }); let onDataOut = line => { @@ -266,8 +267,8 @@ function spawn(name, cmd, waitFor) { }); } -async function spawnNode(name, cmd, waitFor) { - return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor); +function spawnNode(name, cmd, waitFor, options) { + return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options); } before(async function() { diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js index 7034ecaf..6bceeb06 100644 --- a/test/lib/LockFile.test.js +++ b/test/lib/LockFile.test.js @@ -79,6 +79,13 @@ describe("class LockFile", function () { const held = await lock.isHeld(); assert.equal(held, false); }); + + it("should return false if lock file was deleted while held", async function() { + await lock.acquire(); + await fs.unlink(filePath); + const held = await lock.isHeld(); + assert.equal(held, false); + }); }); describe("isStale()", function () { From d6c5430e0c932dd4a64b7aeaa60186051a05144b Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:05:21 +0000 Subject: [PATCH 04/16] Fix headless download --- packages/create/create.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/create/create.js b/packages/create/create.js index fe4318d7..69f17e7d 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -754,8 +754,8 @@ async function inquirerMissingArgs(args) { async function downloadLinuxServer() { let res = await fetch("https://factorio.com/get-download/stable/headless/linux64"); - const url = new URL(res.headers.location); // get the filename of the latest factorio archive from redirected url + const url = new URL(res.url); // The res is redirects, need final url const filename = path.posix.basename(url.pathname); const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/); if (!match || !match.length) { @@ -772,10 +772,7 @@ async function downloadLinuxServer() { if (await fs.pathExists(factorioDir)) { logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`); } else { - await fs.ensureDir(tmpDir); - - // follow the redirect - res = await fetch(url.href); + await fs.emptyDir(tmpDir); logger.info("Downloading latest Factorio server release. This may take a while."); const writeStream = fs.createWriteStream(tmpArchivePath); @@ -794,8 +791,10 @@ async function downloadLinuxServer() { throw e; } - await fs.unlink(archivePath); + await fs.ensureDir("factorio"); await fs.rename(tmpFactorioDir, factorioDir); + await fs.unlink(archivePath); + await fs.rm(tmpDir); } } @@ -821,6 +820,10 @@ async function main() { .option("http-port", { nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number", }) + .option("download-headless", { + describe: "Download the latest headless release [standalone/controller] [linux only]", + type: "boolean", nargs: 0, + }) .option("host-name", { nargs: 1, describe: "Host name [host]", type: "string", }) From 438ccbdc7f34116838294e205683ee7074892644 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:24:03 +0000 Subject: [PATCH 05/16] Fix achievements not being disabled --- packages/host/src/Instance.ts | 9 ++++++-- test/integration/instance.js | 41 +++++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index d3480c46..88cf50ae 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -962,10 +962,15 @@ end`.replace(/\r?\n/g, " "); this._loadedSave = saveName; await this.server.start(saveName); - if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) { + if (this.config.get("factorio.enable_script_commands")) { await this.server.disableAchievements(); - await this.updateInstanceData(); + } + + if (this.config.get("factorio.enable_save_patching")) { this._watchPlayerPromote(); + if (this.config.get("factorio.enable_script_commands")) { + await this.updateInstanceData(); + } } else { this._watchServerLogActions(); } diff --git a/test/integration/instance.js b/test/integration/instance.js index acba6761..decc69dc 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -2,12 +2,14 @@ const assert = require("assert").strict; const lib = require("@clusterio/lib"); +const { testMatrix } = require("../common"); const { slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl, } = require("./index"); const instId = 48; const instName = "Integration"; +const instSetConfig = `instance config set ${instName}`; const instAltId = 49; const instAltName = "IntegrationAlt"; @@ -52,7 +54,6 @@ describe("Clusterio Instance", function() { // Exclude alt test instance from start-all to prevent interference await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`); await execCtl(`instance start ${instAltName}`); - await sendRcon(instAltId, "/sc disable achievements"); }); after(async function() { this.timeout(20000); @@ -61,25 +62,42 @@ describe("Clusterio Instance", function() { await execCtl(`instance stop ${instAltName}`); await execCtl(`instance delete ${instAltName}`); }); - for (const savePatchingEnabled of [true, false]) { - describe(`Integration ${savePatchingEnabled ? "with" : "without"} save patching`, function() { + for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) { + const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`; + const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`; + async function allowSendingScriptCommands() { + if (!scriptCommandsEnabled) { + // Enable command to prevent "Attempted to use script command when disabled" + // This works because we only disable achievements on startup, this will break if we make it live update + await execCtl(`${instSetConfig} factorio.enable_script_commands true`); + } + } + describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() { before(async function() { this.timeout(20000); // Create and start a patched save - await execCtl(`instance config set ${instName} factorio.enable_save_patching ${savePatchingEnabled}`); - await execCtl(`instance save create ${instName} ${savePatchingEnabled ? "patched" : "unpatched"}`); - await execCtl(`instance start ${instName} --save ${savePatchingEnabled ? "patched" : "unpatched"}.zip`); - await sendRcon(instId, "/sc disable achievements"); + await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`); + await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`); + await execCtl(`instance save create ${instName}`); + await execCtl(`instance start ${instName}`); }); after(async function() { // Stop the instance await execCtl(`instance stop ${instName}`); }); - describe("player event", function() { + describe("Disable achievements", function() { + it("should only disable achievements when script commands are enabled", async function() { + await allowSendingScriptCommands(); + const res = await sendRcon(instId, "/sc rcon.print('Disabled')"); + assert.equal(res === "Disabled\n", scriptCommandsEnabled); + }); + }); + describe("Player events", function() { if (savePatchingEnabled) { // IPC expects save patching to be enabled it("should do nothing for an unknown type", async function() { await assert.rejects(getUser("DoesNotExist"), "User should not exist"); + await allowSendingScriptCommands(); await sendRcon(instId, `/sc ${requireApi} api.send_json("player_event",` + "{ type='invalid type', name='DoesNotExist' })" @@ -87,6 +105,7 @@ describe("Clusterio Instance", function() { await assert.rejects(getUser("DoesNotExist"), "User was created"); }); it("should respond to a player join and leave event", async function() { + await allowSendingScriptCommands(); await sendRcon(instId, `/sc ${requireApi} api.send_json("player_event",` + "{ type='join', name='JoiningPlayer' })" @@ -102,7 +121,7 @@ describe("Clusterio Instance", function() { }); } it("should respond to a player ban and unban event", async function() { - await execCtl(`instance config set ${instName} factorio.sync_banlist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`); await sendRcon(instId, "/ban BannedPlayer"); await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms const userBanned = await getUser("BannedPlayer"); @@ -119,7 +138,7 @@ describe("Clusterio Instance", function() { }); it("should respond to a player promote and demote event", async function() { // Because there is no admin list command this test will be different to the other two - await execCtl(`instance config set ${instName} factorio.sync_adminlist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_adminlist bidirectional`); await sendRcon(instId, "/promote AdminPlayer"); await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms const userPromote = await getUser("AdminPlayer"); @@ -141,7 +160,7 @@ describe("Clusterio Instance", function() { ); }); it("should respond to a player whitelist add and remove event", async function() { - await execCtl(`instance config set ${instName} factorio.sync_whitelist bidirectional`); + await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`); await sendRcon(instId, "/whitelist add WhitelistPlayer"); await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms const userAdded = await getUser("WhitelistPlayer"); From 4901d76ebe125d58f3254334b6c0354f4c45e4eb Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:37:06 +0000 Subject: [PATCH 06/16] Refactor version inputs --- packages/lib/src/config/definitions.ts | 1 + packages/web_ui/src/bootstrap.tsx | 2 + .../src/components/InputPartialVersion.tsx | 47 +++++++++++++++++ .../src/components/InputTargetVersion.tsx | 50 +++++++++++++++++++ .../web_ui/src/components/ModPackViewPage.tsx | 9 ++-- packages/web_ui/src/components/ModsPage.tsx | 6 ++- 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/web_ui/src/components/InputPartialVersion.tsx create mode 100644 packages/web_ui/src/components/InputTargetVersion.tsx diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b948a04c..18d30609 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -479,6 +479,7 @@ export class InstanceConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "latest", + inputComponent: "target_version", }, "factorio.executable_path": { description: diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 36c018f4..ce025ee6 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -9,6 +9,7 @@ import App from "./components/App"; import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; +import InputTargetVersion from "./components/InputTargetVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -111,6 +112,7 @@ function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { "mod_pack": InputModPack, "role": InputRole, + "target_version": InputTargetVersion, }; for (let [pluginName, plugin] of plugins) { for (const [name, Component] of Object.entries(plugin.inputComponents)) { diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx new file mode 100644 index 00000000..ac38da0e --- /dev/null +++ b/packages/web_ui/src/components/InputPartialVersion.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Select } from "antd"; + +import { InputComponentProps } from "../BaseWebPlugin"; +import * as lib from "@clusterio/lib"; + +// Hardcoded list for step 1 +const FACTORIO_VERSIONS = [ + "2.0", + "1.1", + "1.0", + "0.18", + "0.17", + "0.16", + "0.15", + "0.14", + "0.13", +] as const; + +const defaultFieldDefinition: lib.FieldDefinition = { + type: "string", + optional: false, +}; + +export default function InputPartialVersion( + props: Omit & { fieldDefinition?: lib.FieldDefinition; className?: string } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + + const options = FACTORIO_VERSIONS.map(version => ({ + label: version, + value: version, + })); + + return props.onChange(value ?? null)} + value={props.value} + options={options} + allowClear={fieldDefinition.optional} + disabled={props.disabled} + placeholder="Select Factorio version" + />; +} diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 4a185a1c..e7e84bc8 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -26,6 +26,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; +import InputPartialVersion from "./InputPartialVersion"; const { logger } = lib; const { Text } = Typography; @@ -1288,9 +1289,11 @@ export default function ModPackViewPage() { message: "Must be an a.b or a.b.c version number.", }]} > - + form.setFieldValue("factorioVersion", value)} + className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} + /> diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 7ab5d5d9..0c526b9d 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -21,6 +21,7 @@ import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; import { Dropzone } from "./Dropzone"; import UploadButton from "./UploadButton"; +import InputPartialVersion from "./InputPartialVersion"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -128,7 +129,10 @@ function CreateModPackButton() { message: "Must be an a.b or a.b.c version number.", }]} > - + form.setFieldValue("factorioVersion", value)} + /> From 07f501a67a1a921586cecfbca6a29f430326971c Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:50:20 +0000 Subject: [PATCH 07/16] Add ValueCache and fetchFactorioVersions --- .gitignore | 3 + packages/lib/index.ts | 2 + packages/lib/src/ValueCache.ts | 64 +++++++++ packages/lib/src/external/FactorioVersions.ts | 65 +++++++++ packages/lib/src/external/index.ts | 5 + test/lib/ValueCache.test.js | 127 ++++++++++++++++++ test/lib/external/FactorioVersions.test.js | 122 +++++++++++++++++ 7 files changed, 388 insertions(+) create mode 100644 packages/lib/src/ValueCache.ts create mode 100644 packages/lib/src/external/FactorioVersions.ts create mode 100644 packages/lib/src/external/index.ts create mode 100644 test/lib/ValueCache.test.js create mode 100644 test/lib/external/FactorioVersions.test.js diff --git a/.gitignore b/.gitignore index eb984bd0..1ed48979 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ dist/ node_modules Factorio* +!*Factorio*.ts +!*Factorio*.tsx +!*Factorio*.js /factorio !/test/file/factorio !/test/lib/factorio diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..4f92d7c4 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -9,6 +9,7 @@ export * from "./src/config"; export * from "./src/data"; export * from "./src/database"; export * from "./src/errors"; +export * from "./src/external"; export * from "./src/factorio"; export * from "./src/file_ops"; export * from "./src/hash"; @@ -35,3 +36,4 @@ export * from "./src/rce_ops"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/src/ValueCache.ts b/packages/lib/src/ValueCache.ts new file mode 100644 index 00000000..5b8947de --- /dev/null +++ b/packages/lib/src/ValueCache.ts @@ -0,0 +1,64 @@ + +/** + * Represents an async function which can fetch a value for a ValueCache + */ +export interface ValueCacheFetch { + (): Promise; +} + +/** + * In-memory single value cache + * + * Stores the last time a value was fetched and measures staleness + * Concurrent calls will share the same in-flight fetch + */ +export default class ValueCache { + private ongoingFetch: Promise | null = null; + private timestamp: number | null = null; + private value: T | null = null; // Only valid when timestamp is not null + + /** + * Create a new in memory single value cache + * + * @param fetch A function that fetches and returns a fresh value + */ + constructor( + private readonly fetch: ValueCacheFetch, + ) {} + + /** + * Get the cached value, will fetch a fresh value if stale or missing + * + * @param maxAgeMs Maximum allowed cache age in milliseconds. + * Defaults to 0, meaning it will always fetch a new value. + * @throws TypeError if maxAgeMs is invalid + * @throws Propagates errors from underlying fetch method + */ + get(maxAgeMs: number = 0): Promise { + if (maxAgeMs < 0) { + throw new TypeError("maxAgeMs must be a non-negative number"); + } + + const now = Date.now(); + + if (this.timestamp && now - this.timestamp < maxAgeMs) { + return Promise.resolve(this.value as T); + } + + if (this.ongoingFetch) { + return this.ongoingFetch; + } + + this.ongoingFetch = this.fetch() + .then(value => { + this.timestamp = Date.now(); + this.value = value; + return value; + }) + .finally(() => { + this.ongoingFetch = null; + }); + + return this.ongoingFetch; + } +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts new file mode 100644 index 00000000..9760460b --- /dev/null +++ b/packages/lib/src/external/FactorioVersions.ts @@ -0,0 +1,65 @@ +import { FullVersion, isFullVersion } from "../data"; + +const ARCHIVE_URL = "https://factorio.com/download/archive"; + +/** + * Represents the parsed result of a factorio version + */ +export type ExternalFactorioVersion = { + stable: boolean; + version: FullVersion; + downloadUrl: string; +}; + +/** + * Fetch all factorio versions that support headless + * + * @returns A list of all factorio versions and their download URL + * @throws Network errors and non-ok status responses + */ +export async function fetchFactorioVersions(): Promise { + let res; + + try { + res = await fetch(ARCHIVE_URL); + } catch (err) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`); + } + + if (!res.ok) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`); + } + + const html = await res.text(); + return parseFactorioVersions(html); +} + +/** + * Parse all versions from the factorio archive webpage + * + * @param html The HTML content of the archive page + * @returns A list of all factorio versions and their download URL + */ +function parseFactorioVersions(html: string): ExternalFactorioVersion[] { + const versions = new Map(); + + // Match 2.0.72 + // eslint-disable-next-line max-len + const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; + + let match: RegExpExecArray | null; + while ((match = anchorRegex.exec(html)) !== null) { + const kind = match.groups?.kind; + const version = match.groups?.version; + + if (kind && version && isFullVersion(version)) { + versions.set(version, { + version: version, + stable: kind === "stable", + downloadUrl: `${ARCHIVE_URL}/${version}`, + }); + } + } + + return [...versions.values()]; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts new file mode 100644 index 00000000..92cc1f2e --- /dev/null +++ b/packages/lib/src/external/index.ts @@ -0,0 +1,5 @@ +/** + * Library for interfacing with external apis + * @module lib/external + */ +export * from "./FactorioVersions"; diff --git a/test/lib/ValueCache.test.js b/test/lib/ValueCache.test.js new file mode 100644 index 00000000..b75094c3 --- /dev/null +++ b/test/lib/ValueCache.test.js @@ -0,0 +1,127 @@ +const assert = require("assert").strict; +const { ValueCache, wait } = require("@clusterio/lib"); + +describe("ValueCache", function () { + describe("constructor", function () { + it("constructs with a fetch function", function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + assert.ok(cache); + }); + }); + + describe("get", function() { + it("throws TypeError for negative maxAgeMs", async function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + + assert.throws(() => cache.get(-1), err => { + assert.equal(err instanceof TypeError, true); + assert.match(String(err.message), /maxAgeMs/); + return true; + }); + }); + it("uses a default of 0 for maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(); + const v2 = await cache.get(); + + assert.equal(v1, 1); + assert.equal(v2, 2); + assert.equal(callCount, 2); + }); + it("returns cached value when within maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(1000); + const v2 = await cache.get(1000); + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(callCount, 1); + }); + it("refreshes the cache when older than maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(50); + const v2 = await cache.get(1000); // Use cached value + await wait(100); + const v3 = await cache.get(50); // Stale after 100ms wait + const v4 = await cache.get(1000); // Use cached value + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(v3, 2); + assert.equal(v4, 2); + assert.equal(callCount, 2); + }); + it("shares in-flight fetch between concurrent callers", async function() { + let callCount = 0; + let resolveFetcher; + const fetcher = () => ( + new Promise(resolve => { + callCount += 1; + resolveFetcher = resolve; + }) + ); + + const cache = new ValueCache(fetcher); + + const p1 = cache.get(1000); + const p2 = cache.get(1000); + + assert.equal(callCount, 1); + + resolveFetcher(42); + const [v1, v2] = await Promise.all([p1, p2]); + + assert.equal(v1, 42); + assert.equal(v2, 42); + assert.equal(callCount, 1); + }); + it("propagates errors from the fetcher and clears ongoing fetch", async function() { + let callCount = 0; + const error = new Error("fetch failed"); + const fetcher = async () => { + callCount += 1; + throw error; + }; + + const cache = new ValueCache(fetcher); + + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 1); + + // After a failure, a new attempt to fetch should be made + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 2); + }); + }); +}); diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js new file mode 100644 index 00000000..30e4f3ae --- /dev/null +++ b/test/lib/external/FactorioVersions.test.js @@ -0,0 +1,122 @@ +const assert = require("assert").strict; +const { readFile } = require("fs-extra"); + +const { fetchFactorioVersions } = require("@clusterio/lib"); +const { slowTest } = require("../../integration"); + +describe("FactorioVersions", function() { + describe("fetchFactorioVersions", function() { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = undefined; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockFetch(html) { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + text: async () => html, + }; + + global.fetch = async () => mockResponse; + } + + it("fetches and parsed versions with stable / experimental flags", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.72 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { stable: false, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, + { stable: true, version: "2.0.72", downloadUrl: "https://factorio.com/download/archive/2.0.72" }, + ]); + }); + it("deduplicates versions by last occurrence", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.73 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { stable: true, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, + ]); + }); + it("thorws on HTTP error status", async function() { + const mockResponse = { + ok: false, + status: 500, + statusText: "Server Error", + text: async () => "", + }; + + global.fetch = async () => mockResponse; + + await assert.rejects(fetchFactorioVersions(), /HTTP 500 Server Error/); + }); + it("thorws on network error status", async function() { + global.fetch = async () => { + throw new Error("Network Error"); + }; + + await assert.rejects(fetchFactorioVersions(), /Network Error/); + }); + it("parses versions from saved html file", async function() { + mockFetch(await readFile("test/file/html/factorio-download-archive.html")); + + const versions = await fetchFactorioVersions(); + assert.equal(versions.length, 111); + assert.equal(versions.filter(v => v.stable).length, 53); + assert.equal(versions.filter(v => !v.stable).length, 58); + }); + it("parses versions from live api", async function() { + slowTest(this); + global.fetch = originalFetch; + + const versions = await fetchFactorioVersions(); + assert.ok(versions.length >= 111); + assert.ok(versions.filter(v => v.stable).length >= 53); + assert.ok(versions.filter(v => !v.stable).length >= 58); + }); + }); +}); From 6f68cf74a7f73a4eead6668ca561292de915ad00 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:27:28 +0000 Subject: [PATCH 08/16] Add network events and switch to tree select --- packages/controller/src/ControlConnection.ts | 5 ++ packages/controller/src/Controller.ts | 2 + packages/host/src/server.ts | 24 ++++-- packages/lib/browser.ts | 1 + packages/lib/src/data/messages_controller.ts | 23 +++++ packages/lib/src/external/FactorioVersions.ts | 20 +++-- packages/lib/src/link/messages.ts | 1 + packages/lib/src/permissions.ts | 6 ++ packages/lib/tsconfig.browser.json | 2 + packages/web_ui/src/bootstrap.tsx | 4 +- .../src/components/InputPartialVersion.tsx | 83 +++++++++++------- .../src/components/InputTargetVersion.tsx | 85 ++++++++++++------- packages/web_ui/src/util/websocket.ts | 7 ++ 13 files changed, 184 insertions(+), 79 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 332a19ed..905ef956 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -126,6 +126,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); + this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -1222,6 +1223,10 @@ export default class ControlConnection extends BaseConnection { this.connector._socket!.clusterio_ignore_dump = true; this._controller.debugEvents.on("message", this.ws_dumper); } + + async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { + return await this._controller.factorioVersions.get(request.maxAgeMs); + } } module.exports = ControlConnection; diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a22181b1..7c366a73 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -105,6 +105,8 @@ export default class Controller { // Cache for mod portal requests modPortalCache = new Map(); + // Cache for factorio versions + factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions); static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 4ffda317..dc45d101 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: lib.TargetVersion) { +async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt")); if (simpleVersion !== null) { - if (simpleVersion === targetVersion || targetVersion === "latest") { - return [path.join(factorioDir, "data"), simpleVersion]; + if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") { + if (lib.isFullVersion(simpleVersion)) { + return [path.join(factorioDir, "data"), simpleVersion]; + } } throw new Error( @@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion ); } - let versions = new Map(); + let versions = new Map(); for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt")); - if (version === null) { + if (version === null || !lib.isFullVersion(version)) { continue; } @@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion throw new Error(`Unable to find any Factorio install in ${factorioDir}`); } + if (lib.isPartialVersion(targetVersion)) { + const sorted = [...versions.keys()].sort(versionOrder); + const latest = sorted.find(version => version.startsWith(targetVersion)); + if (latest) { + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; + } + } + if (targetVersion === "latest") { - let latest = [...versions.keys()].sort(versionOrder)[0]; - return [path.join(factorioDir, versions.get(latest), "data"), latest]; + const latest = [...versions.keys()].sort(versionOrder)[0]; + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; } throw new Error(`Unable to find Factorio version ${targetVersion}`); diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 31fd837f..58c0debf 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -15,3 +15,4 @@ export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index dfcc368c..aa4232a5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum, StringKey, plainJson } from "./composites"; import { levels } from "../logging"; import { ControllerConfig, HostConfig } from "../config"; +import { ExternalFactorioVersionSchema } from "../external"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; @@ -391,3 +392,25 @@ export class DebugWsMessageEvent { return new this(json.direction, json.content); } } + +export class FactorioVersionsRequest { + declare ["constructor"]: typeof FactorioVersionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.external.get_factorio_versions" as const; + + constructor( + public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes + ) {} + + static jsonSchema = Type.Object({ + "maxAgeMs": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.maxAgeMs); + } + + static Response = plainJson(Type.Array(ExternalFactorioVersionSchema)); +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts index 9760460b..b9041fc0 100644 --- a/packages/lib/src/external/FactorioVersions.ts +++ b/packages/lib/src/external/FactorioVersions.ts @@ -1,15 +1,18 @@ -import { FullVersion, isFullVersion } from "../data"; +import { Static, Type } from "@sinclair/typebox"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data"; const ARCHIVE_URL = "https://factorio.com/download/archive"; /** * Represents the parsed result of a factorio version */ -export type ExternalFactorioVersion = { - stable: boolean; - version: FullVersion; - downloadUrl: string; -}; +export const ExternalFactorioVersionSchema = Type.Object({ + stable: Type.Boolean(), + version: FullVersionSchema, + headlessUrl: Type.String(), +}); + +export type ExternalFactorioVersion = Static /** * Fetch all factorio versions that support headless @@ -48,15 +51,16 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] { const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; let match: RegExpExecArray | null; + const minimumMultiplayerVersion = integerFullVersion("0.12.35"); while ((match = anchorRegex.exec(html)) !== null) { const kind = match.groups?.kind; const version = match.groups?.version; - if (kind && version && isFullVersion(version)) { + if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) { versions.set(version, { version: version, stable: kind === "stable", - downloadUrl: `${ARCHIVE_URL}/${version}`, + headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`, }); } } diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 6e29a75f..299cf3ae 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -35,6 +35,7 @@ export const dataClasses: (RequestClass | EventClass) controller.SystemInfoUpdateEvent, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, + controller.FactorioVersionsRequest, subscriptions.SubscriptionRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index fb7395b9..0507a2a4 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -568,3 +568,9 @@ definePermission({ title: "Dump WebSocket", description: "Dump all WebSocket communicatation from the controller.", }); + +definePermission({ + name: "core.external.get_factorio_versions", + title: "Get factorio versions", + description: "Get list of all factorio versions from Wube", +}); diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 059b1ce6..05554ac8 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -6,6 +6,7 @@ "./src/api.ts", "./src/config/*.ts", "./src/data/*.ts", + "./src/external/*.ts", "./src/datastore.ts", "./src/errors.ts", "./src/factorio/exchange_string.ts", @@ -25,6 +26,7 @@ "./src/ExponentialBackoff.ts", "./src/RateLimiter.ts", "./src/TypedEventEmitter.ts", + "./src/ValueCache.ts", ], "compilerOptions": { "rootDir": ".", diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index ce025ee6..3b64bbd5 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -10,6 +10,7 @@ import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; import InputTargetVersion from "./components/InputTargetVersion"; +import InputPartialVersion from "./components/InputPartialVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -110,9 +111,10 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { + "partial_version": InputPartialVersion, + "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, - "target_version": InputTargetVersion, }; for (let [pluginName, plugin] of plugins) { for (const [name, Component] of Object.entries(plugin.inputComponents)) { diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx index ac38da0e..250b5de6 100644 --- a/packages/web_ui/src/components/InputPartialVersion.tsx +++ b/packages/web_ui/src/components/InputPartialVersion.tsx @@ -1,47 +1,68 @@ -import React from "react"; -import { Select } from "antd"; +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; -import { InputComponentProps } from "../BaseWebPlugin"; import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; -// Hardcoded list for step 1 -const FACTORIO_VERSIONS = [ - "2.0", - "1.1", - "1.0", - "0.18", - "0.17", - "0.16", - "0.15", - "0.14", - "0.13", -] as const; +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; -const defaultFieldDefinition: lib.FieldDefinition = { - type: "string", - optional: false, -}; - -export default function InputPartialVersion( - props: Omit & { fieldDefinition?: lib.FieldDefinition; className?: string } +export default function InputPartialVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } ) { const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); - const options = FACTORIO_VERSIONS.map(version => ({ - label: version, - value: version, - })); + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); - return props.onChange(value ?? null)} value={props.value} - options={options} + treeData={tree} allowClear={fieldDefinition.optional} + className={props.className} disabled={props.disabled} - placeholder="Select Factorio version" />; } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 62363569..dbd02325 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -69,6 +69,9 @@ export class Control extends lib.Link { users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this); + /** Cache for factorio versions to avoid repeat calls to the controller */ + factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this)); + declare connector: ControlConnector; constructor( @@ -105,6 +108,10 @@ export class Control extends lib.Link { this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } + requestFactorioVersions() { + return this.send(new lib.FactorioVersionsRequest()); + } + async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) { this.accountRoles = event.roles??null; this.emitAccountUpdate(); From bbf5df6d014e4e873710fd41a86cb4e4bace4775 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:58:36 +0000 Subject: [PATCH 09/16] Add remaining tests --- test/lib/external/FactorioVersions.test.js | 30 +++++++++++----- test/messages/controller.test.js | 40 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js index 30e4f3ae..32b86dad 100644 --- a/test/lib/external/FactorioVersions.test.js +++ b/test/lib/external/FactorioVersions.test.js @@ -51,8 +51,16 @@ describe("FactorioVersions", function() { const versions = await fetchFactorioVersions(); assert.deepEqual(versions, [ - { stable: false, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, - { stable: true, version: "2.0.72", downloadUrl: "https://factorio.com/download/archive/2.0.72" }, + { + stable: false, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, + { + stable: true, + version: "2.0.72", + headlessUrl: "www.factorio.com/get-download/2.0.72/headless/linux64", + }, ]); }); it("deduplicates versions by last occurrence", async function() { @@ -79,7 +87,11 @@ describe("FactorioVersions", function() { const versions = await fetchFactorioVersions(); assert.deepEqual(versions, [ - { stable: true, version: "2.0.73", downloadUrl: "https://factorio.com/download/archive/2.0.73" }, + { + stable: true, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, ]); }); it("thorws on HTTP error status", async function() { @@ -105,18 +117,18 @@ describe("FactorioVersions", function() { mockFetch(await readFile("test/file/html/factorio-download-archive.html")); const versions = await fetchFactorioVersions(); - assert.equal(versions.length, 111); - assert.equal(versions.filter(v => v.stable).length, 53); - assert.equal(versions.filter(v => !v.stable).length, 58); + assert.equal(versions.length, 105); + assert.equal(versions.filter(v => v.stable).length, 48); + assert.equal(versions.filter(v => !v.stable).length, 57); }); it("parses versions from live api", async function() { slowTest(this); global.fetch = originalFetch; const versions = await fetchFactorioVersions(); - assert.ok(versions.length >= 111); - assert.ok(versions.filter(v => v.stable).length >= 53); - assert.ok(versions.filter(v => !v.stable).length >= 58); + assert.ok(versions.length >= 105); + assert.ok(versions.filter(v => v.stable).length >= 48); + assert.ok(versions.filter(v => !v.stable).length >= 57); }); }); }); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 1e345475..7b735e9b 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -34,4 +34,44 @@ describe("messages/controller", function() { ); }); }); + + describe("External", function() { + it("runs", async function() { + let callCount = 0; + const versions = [ + { version: "1.2.3", stable: true, headlessUrl: "path/to/download" }, + { version: "1.2.4", stable: false, headlessUrl: "path/to/download" }, + ]; + + // Mock the cache to avoid needless fetches + controller.factorioVersions = new lib.ValueCache(async () => { + callCount += 1; + return versions; + }); + + // First call should result in a fetch + const versions1 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions1, versions); + assert.equal(callCount, 1); + + // Second call should not fetch due to default maxAgeMs being greater than 0 + const versions2 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions2, versions); + assert.equal(callCount, 1); + + // Setting maxAgeMs to 0 should result in a new fetch + const versions3 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest(0) + ); + + assert.deepEqual(versions3, versions); + assert.equal(callCount, 2); + }); + }); }); From 8981a018b0595e22523a07f660601fedb0b79023 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:33:28 +0000 Subject: [PATCH 10/16] Fix lint issue --- packages/web_ui/src/components/ModPackViewPage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index e7e84bc8..fbf75bdf 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1289,11 +1289,11 @@ export default function ModPackViewPage() { message: "Must be an a.b or a.b.c version number.", }]} > - form.setFieldValue("factorioVersion", value)} - className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} - /> + form.setFieldValue("factorioVersion", value)} + className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} + /> From 02fce63e11e16219e2749448c7a46ca7ef372436 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:03:57 +0000 Subject: [PATCH 11/16] Fix git ignore --- .gitignore | 1 + test/file/html/factorio-download-archive.html | 1583 +++++++++++++++++ 2 files changed, 1584 insertions(+) create mode 100644 test/file/html/factorio-download-archive.html diff --git a/.gitignore b/.gitignore index 1ed48979..293e0bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Factorio* !*Factorio*.js /factorio !/test/file/factorio +!/test/file/html/*.html !/test/lib/factorio !/packages/lib/src/factorio /instances diff --git a/test/file/html/factorio-download-archive.html b/test/file/html/factorio-download-archive.html new file mode 100644 index 00000000..3f5520ee --- /dev/null +++ b/test/file/html/factorio-download-archive.html @@ -0,0 +1,1583 @@ + + + + Download old versions | Factorio + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+ + + + +
+ + + + + + +
+
+

Old version archive

+ + See latest releases + +
+ +
+

+ On this page you can download old versions of Factorio. They will not be as polished and up-to-date as the latest release! +

+
+ Please choose a version. +
+
+ + + + +
+
+ + + 2.0.73 + + + + 2.0.72 + + + + 2.0.71 + + + + 2.0.70 + + + + 2.0.69 + + + + 2.0.68 + + + + 2.0.67 + + + + 2.0.66 + + + + 2.0.65 + + + + 2.0.64 + + + + 2.0.63 + + + + 2.0.62 + + + + 2.0.61 + + + + 2.0.60 + + + + 2.0.59 + + + + 2.0.58 + + + + 2.0.57 + + + + 2.0.56 + + + + 2.0.55 + + + + 2.0.54 + + + + 2.0.53 + + + + 2.0.52 + + + + 2.0.51 + + + + 2.0.50 + + + + 2.0.49 + + + + 2.0.48 + + + + 2.0.47 + + + + 2.0.46 + + + + 2.0.45 + + + + 2.0.44 + + + + 2.0.43 + + + + 2.0.42 + + + + 2.0.41 + + + + 2.0.40 + + + + 2.0.39 + + + + 2.0.38 + + + + 2.0.37 + + + + 2.0.36 + + + + 2.0.35 + + + + 2.0.34 + + + + 2.0.33 + + + + 2.0.32 + + + + 2.0.31 + + + + 2.0.30 + + + + 2.0.29 + + + + 2.0.28 + + + + 2.0.27 + + + + 2.0.26 + + + + 2.0.25 + + + + 2.0.24 + + + + 2.0.23 + + + + 2.0.22 + + + + 2.0.21 + + + + 2.0.20 + + + + 2.0.19 + + + + 2.0.18 + + + + 2.0.17 + + + + 2.0.16 + + + + 2.0.15 + + + + 2.0.14 + + + + 2.0.13 + + + + 2.0.12 + + + + 2.0.11 + + + + 2.0.10 + + + + 2.0.9 + + + + 2.0.8 + + + + 2.0.7 + + + + 1.1.110 + + + + 1.1.109 + + + + 1.1.108 + + + + 1.1.107 + + + + 1.1.106 + + + + 1.1.105 + + + + 1.1.104 + + + + 1.1.103 + + + + 1.1.102 + + + + 1.1.101 + + + + 1.1.100 + + + + 1.1.99 + + + + 1.1.98 + + + + 1.1.97 + + + + 1.1.96 + + + + 1.1.95 + + + + 1.1.94 + + + + 1.1.93 + + + + 1.1.92 + + + + 1.1.91 + + + + 1.1.90 + + + + 1.1.89 + + + + 1.1.88 + + + + 1.1.87 + + + + 1.1.86 + + + + 1.1.69 + + + + 1.1.60 + + + + 1.1.59 + + + + 1.1.58 + + + + 1.1.57 + + + + 1.0.0 + + + + 0.17.79 + + + + 0.16.51 + + + + 0.15.40 + + + + 0.15.36 + + + + 0.14.23 + + + + 0.13.20 + + + + 0.12.35 + + + + 0.11.22 + + + + 0.10.12 + + + + 0.9.8 + + + + 0.8.8 + + + + 0.7.5 + + + + 0.6.4 + + +
+
+ + +
+ + + +
+
+ + + + + + + + + + From ab86cd2b1c61442a0a51c0c4ac0d85f980164790 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:47:40 +0000 Subject: [PATCH 12/16] Update package json and tsconfig --- plugins/global_chat/package.json | 7 ++++++- plugins/global_chat/tsconfig.node.json | 2 ++ plugins/inventory_sync/package.json | 7 ++++++- plugins/inventory_sync/tsconfig.node.json | 2 ++ plugins/player_auth/package.json | 7 ++++++- plugins/player_auth/tsconfig.node.json | 2 ++ plugins/research_sync/package.json | 7 ++++++- plugins/research_sync/tsconfig.node.json | 2 ++ plugins/statistics_exporter/package.json | 5 ++++- plugins/statistics_exporter/tsconfig.node.json | 1 + plugins/subspace_storage/package.json | 7 ++++++- plugins/subspace_storage/tsconfig.node.json | 2 ++ 12 files changed, 45 insertions(+), 6 deletions(-) diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index f5f56e4d..4a00b458 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -23,11 +23,16 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/ctl": "workspace:*", "@clusterio/lib": "workspace:*" }, "devDependencies": { - "@clusterio/lib": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/ctl": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/node": "^20.17.19", "@types/react": "^18.3.18", "typescript": "^5.7.3", diff --git a/plugins/global_chat/tsconfig.node.json b/plugins/global_chat/tsconfig.node.json index ce0e013f..24bc1d0a 100644 --- a/plugins/global_chat/tsconfig.node.json +++ b/plugins/global_chat/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/ctl/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index dc715c09..3a1a0b6c 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -23,6 +23,9 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { @@ -30,8 +33,10 @@ "fs-extra": "^8.1.0" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/fs-extra": "^11.0.1", "@types/node": "^20.17.19", "@types/react": "^18.3.18", diff --git a/plugins/inventory_sync/tsconfig.node.json b/plugins/inventory_sync/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/inventory_sync/tsconfig.node.json +++ b/plugins/inventory_sync/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index 955430fd..ee001650 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -23,6 +23,9 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { @@ -31,8 +34,10 @@ "jsonwebtoken": "^9.0.1" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/express": "^4.17.17", "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.17.19", diff --git a/plugins/player_auth/tsconfig.node.json b/plugins/player_auth/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/player_auth/tsconfig.node.json +++ b/plugins/player_auth/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index 0c64aaf1..ef45c365 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -23,6 +23,9 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { @@ -30,8 +33,10 @@ "fs-extra": "^8.1.0" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/fs-extra": "^11.0.1", "@types/node": "^20.17.19", "@types/react": "^18.3.18", diff --git a/plugins/research_sync/tsconfig.node.json b/plugins/research_sync/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/research_sync/tsconfig.node.json +++ b/plugins/research_sync/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index 2fe688e6..ba440875 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -23,11 +23,14 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "devDependencies": { - "@clusterio/lib": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/node": "^20.17.19", "@types/react": "^18.3.18", "typescript": "^5.7.3", diff --git a/plugins/statistics_exporter/tsconfig.node.json b/plugins/statistics_exporter/tsconfig.node.json index ce0e013f..48c9d359 100644 --- a/plugins/statistics_exporter/tsconfig.node.json +++ b/plugins/statistics_exporter/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 4b7f4cd7..1655c232 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -23,6 +23,9 @@ "node": ">=18" }, "peerDependencies": { + "@clusterio/controller": "workspace:*", + "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*" }, "dependencies": { @@ -30,8 +33,10 @@ "fs-extra": "^8.1.0" }, "devDependencies": { - "@clusterio/lib": "workspace:*", + "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", + "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "@types/express": "^4.17.17", "@types/fs-extra": "^11.0.1", "@types/node": "^20.17.19", diff --git a/plugins/subspace_storage/tsconfig.node.json b/plugins/subspace_storage/tsconfig.node.json index ce0e013f..55d31e46 100644 --- a/plugins/subspace_storage/tsconfig.node.json +++ b/plugins/subspace_storage/tsconfig.node.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.node.json", "references": [ { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], From 2679851d666bbc4c4adb2f29350524ccf9d50ccd Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:54:59 +0000 Subject: [PATCH 13/16] Update plugin template --- packages/create/templates/common/package.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/create/templates/common/package.json b/packages/create/templates/common/package.json index b4ffa659..0e01b9d7 100644 --- a/packages/create/templates/common/package.json +++ b/packages/create/templates/common/package.json @@ -15,6 +15,18 @@ "node": "__node_version__" }, "peerDependencies": { +//%if controller + "@clusterio/controller": "^__clusterio_version__", +//%endif +//%if webpack + "@clusterio/web_ui": "^__clusterio_version__", +//%endif +//%if host + "@clusterio/host": "^__clusterio_version__", +//%endif +//%if ctl + "@clusterio/ctl": "^__clusterio_version__", +//%endif "@clusterio/lib": "^__clusterio_version__" }, "devDependencies": { @@ -36,7 +48,18 @@ "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-merge": "^5.9.0", +//%endif +//%if controller + "@clusterio/controller": "^__clusterio_version__", +//%endif +//%if webpack "@clusterio/web_ui": "^__clusterio_version__", +//%endif +//%if host + "@clusterio/host": "^__clusterio_version__", +//%endif +//%if ctl + "@clusterio/ctl": "^__clusterio_version__", //%endif "@clusterio/lib": "^__clusterio_version__" }, From 1fc69712780d400843b02a9c56ae0c1962f09ca9 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:02:20 +0000 Subject: [PATCH 14/16] Switch to catalogs --- packages/controller/package.json | 54 ++++++++++++------------ packages/create/package.json | 4 +- packages/ctl/package.json | 16 +++---- packages/host/package.json | 22 +++++----- packages/lib/package.json | 30 ++++++------- plugins/global_chat/package.json | 18 ++++---- plugins/inventory_sync/package.json | 24 +++++------ plugins/player_auth/package.json | 28 ++++++------ plugins/research_sync/package.json | 18 ++++---- plugins/statistics_exporter/package.json | 12 +++--- plugins/subspace_storage/package.json | 26 ++++++------ pnpm-workspace.yaml | 32 ++++++++++++++ 12 files changed, 158 insertions(+), 126 deletions(-) diff --git a/packages/controller/package.json b/packages/controller/package.json index 67ef8494..051c84bb 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -21,41 +21,41 @@ }, "dependencies": { "@clusterio/lib": "workspace:*", - "@sinclair/typebox": "^0.30.4", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "jsonwebtoken": "catalog:", + "express": "catalog:", + "fs-extra": "catalog:", + "ws": "catalog:", + "jszip": "catalog:", + "winston": "catalog:", + "winston-daily-rotate-file": "catalog:", "busboy": "^1.6.0", "compression": "^1.8.0", - "express": "^4.21.2", "finalhandler": "^1.3.1", - "fs-extra": "^11.3.0", - "jsonwebtoken": "^9.0.1", - "jszip": "^3.10.1", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^4.7.1", - "ws": "^8.18.1", "yargs": "^17.7.2" }, "devDependencies": { "@clusterio/web_ui": "workspace:*", - "@swc/core": "^1.13.3", - "@types/busboy": "^1.5.0", - "@types/compression": "^1.7.2", - "@types/express": "^4.17.17", - "@types/finalhandler": "^1.2.0", - "@types/fs-extra": "^11.0.1", - "@types/jsonwebtoken": "^9.0.9", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", - "@types/ws": "^8.5.14", + "@types/jsonwebtoken": "catalog:", + "@types/set-blocking": "catalog:", + "@types/finalhandler": "catalog:", + "@types/express": "catalog:", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/ws": "catalog:", "@types/yargs": "^17.0.33", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.29.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-dev-middleware": "^6.1.1", - "webpack-merge": "^5.9.0" + "@types/compression": "^1.7.2", + "@types/busboy": "^1.5.0", + "@swc/core": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:", + "webpack-dev-middleware": "^6.1.1" }, "publishConfig": { "access": "public" diff --git a/packages/create/package.json b/packages/create/package.json index da1cdd54..a323cfe2 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -16,8 +16,8 @@ "node": ">=18" }, "dependencies": { - "chalk": "^4.1.2", - "fs-extra": "^11.3.0", + "chalk": "catalog:", + "fs-extra": "catalog:", "inquirer": "^8.2.4", "yargs": "^17.7.2" }, diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 3f5d67b7..8bf7faf4 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -21,21 +21,21 @@ }, "dependencies": { "@clusterio/lib": "workspace:*", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "winston": "catalog:", + "phin": "catalog:", "as-table": "^1.0.55", - "fs-extra": "^11.3.0", - "phin": "^3.7.0", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/set-blocking": "catalog:", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/packages/host/package.json b/packages/host/package.json index 6fe78bed..1969c419 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -22,29 +22,29 @@ }, "dependencies": { "@clusterio/lib": "workspace:*", - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^11.3.0", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "jszip": "catalog:", + "phin": "catalog:", + "winston": "catalog:", + "winston-daily-rotate-file": "catalog:", "jimp": "^0.22.8", - "jszip": "^3.10.1", - "phin": "^3.7.0", "pidusage": "^3.0.2", "rcon-client": "^4.2.5", "semver": "^7.7.1", - "set-blocking": "^2.0.0", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^4.7.1", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", + "@types/node": "catalog:", + "@types/fs-extra": "catalog:", + "@types/set-blocking": "catalog:", "@types/pidusage": "^2.0.2", "@types/semver": "^7.5.0", - "@types/set-blocking": "^2.0.0", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/packages/lib/package.json b/packages/lib/package.json index 187ac1eb..74e4f560 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -18,30 +18,30 @@ "node": ">=12" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "ajv": "^8.17.1", - "chalk": "^4.1.2", + "@sinclair/typebox": "catalog:", + "set-blocking": "catalog:", + "fs-extra": "catalog:", + "jszip": "catalog:", + "chalk": "catalog:", + "ws": "catalog:", + "winston": "catalog:", + "winston-transport": "catalog:", "fast-deep-equal": "^3.1.3", - "fs-extra": "^11.3.0", - "jszip": "^3.10.1", - "klaw": "^4.1.0", - "set-blocking": "^2.0.0", "triple-beam": "^1.4.1", - "winston": "^3.17.0", - "winston-transport": "^4.9.0", - "ws": "^8.18.1", + "ajv": "^8.17.1", + "klaw": "^4.1.0", "yargs": "^17.7.2" }, "publishConfig": { "access": "public" }, "devDependencies": { - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/set-blocking": "^2.0.0", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/ws": "catalog:", + "@types/set-blocking": "catalog:", "@types/triple-beam": "^1.3.2", - "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", - "typescript": "^5.7.3" + "typescript": "catalog:" } } diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index 4a00b458..23c3ed96 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -28,22 +28,22 @@ "@clusterio/ctl": "workspace:*", "@clusterio/lib": "workspace:*" }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, "devDependencies": { "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/ctl": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" - }, - "dependencies": { - "@sinclair/typebox": "^0.30.4" } } diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index 3a1a0b6c..b1ae2d0b 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -29,24 +29,24 @@ "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index ee001650..2579e2dc 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -29,26 +29,26 @@ "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "express": "^4.21.2", - "jsonwebtoken": "^9.0.1" + "@sinclair/typebox": "catalog:", + "jsonwebtoken": "catalog:", + "express": "catalog:" }, "devDependencies": { "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/express": "^4.17.17", - "@types/jsonwebtoken": "^9.0.9", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/express": "catalog:", + "@types/jsonwebtoken": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index ef45c365..3380a5cf 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -29,21 +29,21 @@ "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index ba440875..a01337f1 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -31,12 +31,12 @@ "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 1655c232..480f28cc 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -29,25 +29,25 @@ "@clusterio/lib": "workspace:*" }, "dependencies": { - "@sinclair/typebox": "^0.30.4", - "fs-extra": "^8.1.0" + "@sinclair/typebox": "catalog:", + "fs-extra": "catalog:" }, "devDependencies": { "@clusterio/controller": "workspace:*", "@clusterio/web_ui": "workspace:*", "@clusterio/host": "workspace:*", "@clusterio/lib": "workspace:*", - "@types/express": "^4.17.17", - "@types/fs-extra": "^11.0.1", - "@types/node": "^20.17.19", - "@types/react": "^18.3.18", - "antd": "^5.24.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.7.3", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^5.9.0" + "@types/express": "catalog:", + "@types/fs-extra": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" }, "publishConfig": { "access": "public" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 64d14f73..29bc79e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,36 @@ packages: - "external_plugins/*/*" - "test/" +catalog: + "@types/node": ^20.17.19 + "@types/fs-extra": ^11.0.1 + "@types/express": ^4.17.17 + "@types/finalhandler": ^1.2.0 + "@types/jsonwebtoken": ^9.0.9 + "@types/set-blocking": ^2.0.0 + "@types/react": ^18.3.18 + "@types/ws": ^8.5.14 + "@sinclair/typebox": ^0.30.4 + "@swc/core": ^1.13.3 + "antd": ^5.24.2 + "react": ^18.2.0 + "react-dom": ^18.2.0 + "react-router-dom": ^6.29.0 + "typescript": ^5.7.3 + "webpack": ^5.98.0 + "webpack-cli": ^5.1.4 + "webpack-merge": ^5.9.0 + "fs-extra": ^8.1.0 + "express": ^5.1.0 + "winston": ^3.17.0 + "winston-transport": ^4.9.0 + "winston-daily-rotate-file": ^4.7.1 + "jsonwebtoken": ^9.0.2 + "set-blocking": ^2.0.0 + "jszip": ^3.10.1 + "chalk": ^4.1.2 + "phin": ^3.7.0 + "ws": ^8.18.1 + +catalogMode: strict linkWorkspacePackages: true From 2928b9b7c5c78d1b6fccf17ff7f4803d400065a1 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:23:09 +0000 Subject: [PATCH 15/16] Bump node and pnpm version --- .github/workflows/node.js.yml | 10 +++++----- package.json | 3 ++- packages/controller/package.json | 2 +- packages/create/package.json | 2 +- packages/ctl/package.json | 2 +- packages/host/package.json | 2 +- plugins/global_chat/package.json | 2 +- plugins/inventory_sync/package.json | 2 +- plugins/player_auth/package.json | 2 +- plugins/research_sync/package.json | 2 +- plugins/statistics_exporter/package.json | 2 +- plugins/subspace_storage/package.json | 2 +- 12 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index f0770a01..5a8d9485 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 10 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -49,10 +49,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 10 - uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: pnpm i --no-frozen-lockfile - run: pnpm run lint @@ -62,10 +62,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 10 - uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: pnpm i --no-frozen-lockfile - run: pnpm run build-mod # Upload build to artifacts diff --git a/package.json b/package.json index 11d5cdbb..672fabe8 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "root", "private": true, "files": [], + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", "scripts": { "build": "tsc --build", "watch": "tsc --build --watch", @@ -27,7 +28,7 @@ ] }, "engines": { - "node": ">=18" + "node": ">=20" }, "workspaces": [ "packages/*", diff --git a/packages/controller/package.json b/packages/controller/package.json index 051c84bb..9f350bd6 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -17,7 +17,7 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", diff --git a/packages/create/package.json b/packages/create/package.json index a323cfe2..b26a0e45 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -13,7 +13,7 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "chalk": "catalog:", diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 8bf7faf4..af4819ed 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -17,7 +17,7 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", diff --git a/packages/host/package.json b/packages/host/package.json index 1969c419..638f1501 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -18,7 +18,7 @@ "factorio" ], "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { "@clusterio/lib": "workspace:*", diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index 23c3ed96..14f0f38d 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/web_ui": "workspace:*", diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index b1ae2d0b..73e69188 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/factorioClusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/controller": "workspace:*", diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index 2579e2dc..358f04c4 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/controller": "workspace:*", diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index 3380a5cf..29704b02 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/controller": "workspace:*", diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index a01337f1..683cf207 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/web_ui": "workspace:*", diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 480f28cc..63187fe0 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -20,7 +20,7 @@ "url": "https://github.com/clusterio/clusterio/issues" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@clusterio/controller": "workspace:*", From 0c56c56f7491dc703a1107a3b16146ee74aa3408 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:26:53 +0000 Subject: [PATCH 16/16] Fixed duplicate pnpm version spec --- .github/workflows/node.js.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5a8d9485..3ba681ab 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,8 +20,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -48,8 +46,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20.x @@ -61,8 +57,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: node-version: 20.x