Compare commits

...
Author SHA1 Message Date
Cooldude2606 77f4aa6296 2.0.0-alpha.22b 2025-11-07 21:55:35 +00:00
Cooldude2606 86a9084d56 Fix package updater 2025-11-07 21:48:09 +00:00
Cooldude2606 43def44136 Fix package path for restart required 2025-11-07 21:47:48 +00:00
Cooldude2606 8c1be7210e Skip git checks on npm publish 2025-11-07 20:08:22 +00:00
Cooldude2606 24e290746d 2.0.0-alpha.22 2025-11-07 19:48:21 +00:00
Cooldude2606 d52931a326 Better handling of mod dependencies (#801)
* Add ModDependency and version types

* Add warnings for mod dependencies

* Add warning for wrong factorio version

* Add fix issues button

* Add automatic dependency resolution and download

* Add abilty to check for updates

* Add extra test coverage

* One extra test

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

* Add plugin update checking

* Add restart icon to instance list

* Add tests

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

* Add publish script and workflow

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

* Remove check for permission

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

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+45
View File
@@ -31,6 +31,51 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
+3 -3
View File
@@ -86,8 +86,8 @@ async function removeExecutionArtifacts() {
async function main() {
await yargs(process.argv.slice(2))
.scriptName("clean")
.usage("$0 <command> [options]")
.option("dry", {
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not delete any files",
default: false,
@@ -108,7 +108,7 @@ async function main() {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "You need to specify a command to run")
.demandCommand(1, "A command must be specified")
.strict()
.parse();
}
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
+1 -1
View File
@@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: string, sha1?: string }) {
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
+172 -44
View File
@@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -124,7 +126,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -564,52 +565,181 @@ export default class ControlConnection extends BaseConnection {
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
const { name, version, factorioVersion } = request;
const filename = lib.ModInfo.filename(name, version);
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
const factorioVersion = request.factorioVersion;
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
// Check if mod already exists
if (this._controller.modStore.files.has(filename)) {
logger.verbose(`Mod ${filename} already exists, skipping download.`);
return this._controller.modStore.files.get(filename)!;
}
if (toDownload.length > 0) {
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || !token) {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
const modMap = new Map([[name, version]]);
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
// Verify download and return ModInfo
const downloadedMod = this._controller.modStore.files.get(filename);
if (!downloadedMod) {
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
if (!username || username === "" || !token || token === "") {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
logger.info(`Successfully downloaded mod ${filename}.`);
return downloadedMod;
} catch (error: any) {
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = `Mod portal download failed for ${name} v${version}`;
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
try {
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
} catch (error: any) {
logger.error(`Error downloading mods from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = "Mod portal download failed";
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
// Verify all mods are present
const modInfos = [];
const installedMods = [...this._controller.modStore.mods()];
for (const mod of request.mods) {
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
if (!modInfo) {
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
}
modInfos.push(modInfo);
}
return modInfos;
}
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const builtInModNames = new Set(_builtInModNames);
const skipSearch = new Set(_builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const incompatible = new Set<string>();
const invalid = new Set<string>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
let releases = _modPortalReleases.get(modName);
if (releases === undefined) {
try {
releases = await lib.ModStore.fetchModReleases(modName);
} catch (error: any) {
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
logger.error(`Error fetching mod from portal: ${error.message}`);
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
} else {
releases = null;
}
}
}
_modPortalReleases.set(modName, releases);
return releases;
}
while (searchQueue.length > 0) {
const mod = searchQueue.shift()!;
if (mod.incompatible) {
incompatible.add(mod.name);
continue;
}
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
}
// Update the version range if needed
if (mod.version) {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
invalid.add(mod.name);
}
}
// Do not add dependencies for invalids, optionals, skips, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
}
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const modReleases = await fetchModReleases(mod.name);
if (modReleases && modReleases.releases) {
const release = modReleases.releases
.filter(info => (
info.info_json.factorio_version === factorioVersion
&& versionRange.testVersion(info.version)
))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) {
candidate = lib.ModInfo.fromJSON({
...release.info_json, // Also includes dependences
sha1: release.sha1,
version: release.version,
name: modReleases.name,
author: modReleases.owner,
title: modReleases.title,
});
}
}
}
// Queue the dependencies of the new candidate
if (candidate) {
candidateReleases.set(mod.name, candidate);
for (const dep of candidate.dependencies) {
searchQueue.push(dep);
}
} else {
errorMessage += `: ${error.message}`;
candidateReleases.delete(mod.name);
skipSearch.add(mod.name);
}
throw new lib.RequestError(errorMessage);
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()],
[...invalid.values(), ...[...incompatible.values()].filter(mod => (
candidateReleases.has(mod) || builtInModNames.has(mod)
))],
[...dependencyRequirements.keys()].filter(mod => (
!candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod)
)),
);
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
@@ -1058,7 +1188,7 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/controller");
return await lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1079,9 +1209,7 @@ export default class ControlConnection extends BaseConnection {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
) as boolean,
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
+56 -15
View File
@@ -135,7 +135,8 @@ export default class Controller {
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack)
lib.ModPack.fromJSON.bind(lib.ModPack),
this.migrateModPacks,
).bootstrap());
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
@@ -549,19 +550,8 @@ export default class Controller {
}
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("controller.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
}
requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired));
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -571,6 +561,46 @@ export default class Controller {
}
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/controller";
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -673,6 +703,17 @@ export default class Controller {
return instance;
}
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
}
return json;
});
}
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
return serialized.map(json => {
@@ -1127,7 +1168,7 @@ export default class Controller {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
continue;
}
+2 -2
View File
@@ -11,7 +11,7 @@ export default class InstanceInfo {
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: lib.TargetVersion,
public updatedAtMs = 0,
) {
this.config = config;
@@ -23,7 +23,7 @@ export default class InstanceInfo {
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
+1 -3
View File
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21c",
"version": "2.0.0-alpha.22",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+2 -2
View File
@@ -94,8 +94,8 @@ async function copyPluginTemplates(pluginName, templates) {
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
+15 -6
View File
@@ -1362,7 +1362,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
@@ -1415,8 +1415,8 @@ modPackCommands.add(new lib.Command({
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
@@ -1496,8 +1496,8 @@ modPackCommands.add(new lib.Command({
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
@@ -1551,6 +1551,9 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
@@ -1638,7 +1641,7 @@ modCommands.add(new lib.Command({
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
args.factorioVersion,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
@@ -1717,6 +1720,9 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
@@ -1757,6 +1763,9 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
+2 -1
View File
@@ -3,10 +3,11 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
+46 -16
View File
@@ -410,7 +410,7 @@ export default class Host extends lib.Link {
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -693,7 +693,8 @@ export default class Host extends lib.Link {
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
: instanceInfo.config.get("factorio.version"),
// TODO: factorio.version is not validated
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
)
);
@@ -805,19 +806,48 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("host.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
let packageName = "@clusterio/host";
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async handleHostMetricsRequest() {
@@ -1066,7 +1096,7 @@ export default class Host extends lib.Link {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/host");
return await lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1087,7 +1117,7 @@ export default class Host extends lib.Link {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.plugins.has(pluginInfo.name),
this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean,
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
+5 -3
View File
@@ -210,7 +210,8 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
version: this.config.get("factorio.version"),
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
@@ -553,7 +554,8 @@ end`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
status === "running"? this.server.version : this.config.get("factorio.version"),
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
);
}
@@ -651,7 +653,7 @@ end`.replace(/\r?\n/g, " ");
if (
!pluginInfo.instanceEntrypoint
|| !this._host.serverPlugins.has(pluginInfo.name)
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
+5 -5
View File
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
* @returns Array with path to data dir and version found.
* @internal
*/
async function findVersion(factorioDir: string, targetVersion: string) {
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
@@ -423,7 +423,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: string,
version?: lib.TargetVersion,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -523,7 +523,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_writeDir: string;
// Resolved in init
_version: string | null = null;
_version: lib.FullVersion | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
@@ -532,7 +532,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: string;
_targetVersion: lib.TargetVersion;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
@@ -866,7 +866,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
get version(): string | undefined {
get version(): lib.FullVersion | undefined {
return this._version ?? undefined;
}
+14 -2
View File
@@ -115,9 +115,21 @@ async function main() {
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
"source-dir": {
describe: "Path to mod source files",
type: "string",
nargs: 1,
normalize: true,
demandOption: true,
},
"output-dir": {
describe: "Path to output built mod(s)",
type: "string",
nargs: 1,
normalize: true,
default: "dist/factorio",
},
})
.strict()
.argv
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
+103 -65
View File
@@ -3,10 +3,10 @@ import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo, ModPack } from "./data";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
super();
}
getMod(name: string, version: string, sha1?: string) {
getMod(name: string, version: FullVersion, sha1?: string) {
const mod = this.files.get(ModInfo.filename(name, version));
if (!mod || sha1 && sha1 !== mod.sha1) {
return undefined;
@@ -87,11 +87,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: string) {
async loadMod(name: string, version: FullVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: string) {
async deleteMod(name: string, version: FullVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -155,10 +155,46 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
return new this(modsDirectory, files);
}
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
/**
* Stream a url's content into a file.
*
* @param url - Web path to stream from.
* @param filePath - File path to stream to.
*/
private static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
* Download a matching version from a selection of available versions.
*
* @param releases - Array of mod releases taken from the mod portal.
* @param version - The required version to install.
* @param username - Factorio username to download using.
* @param token - Factorio token to download using.
*/
private async downloadModFromReleases(
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
username: string, token: string
) {
const selectedRelease = releases.find(release => release.version === version);
const selectedRelease = releases.find(release => version.testVersion(release.version));
if (selectedRelease === undefined) {
logger.warn(`Mod release matching version ${version} not found.`);
@@ -190,96 +226,83 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
}
}
/**
* Downloads all provided mods, does not perform chunking or filtering of builtins
*
* @param mods - An array of mods to download
* @param username - Factorio username used for auth to download mods
* @param token - Factorio token used for auth to download mods
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
private async downloadModsChunk(
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
url.searchParams.set("version", factorioVersion);
const response = await fetch(url, {
method: "POST",
body: new URLSearchParams({ namelist: modNames.join(",") }),
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
});
if (response.status !== 200) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
const mods = (await response.json() as ModsInfoResponse).results;
await Promise.all(mods.map((mod) => {
const modReleases = (await response.json() as ModsInfoResponse).results;
await Promise.all(modReleases.map((mod) => {
const modVersion = mods.find(m => m.name === mod.name)!.version;
if (mod.releases) {
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
/**
* Downloads all provided mods to the mod dir.
*
* downloads mods to the mod dir, will not download mods for which are already in the mod directory
*
* @param modMap a map of what mods to download and what version to download
* @param username factorio username used for auth to download mods
* @param token factorio token used for auth to download mods
* @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions)
* Handles chunking to prevent errors due to URL length.
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
*
* @param mods - An array of mods to download.
* @param username - Factorio username used for auth to download mods.
* @param token - Factorio token used for auth to download mods.
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
) {
const chunkSize = 500;
let futures: Promise<void>[] = [];
modMap = new Map(modMap);
// Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks
const versionParts = factorioVersion.split(".");
if (versionParts.length > 2) {
factorioVersion = versionParts.slice(0, 2).join(".");
}
const futures: Promise<void>[] = [];
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
// get rid of mods already downloaded, built-in, or core
modMap.forEach((modVersion, modName, map) => {
const filename = `${modName}_${modVersion}.zip`;
// Filter existing, built-in from getBuiltinModNames, and explicitly filter "core"
if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") {
map.delete(modName);
}
});
const modNames = Array.from(modMap.keys());
const modVersions = Array.from(modMap.values());
for (let i = 0; i < modMap.size; i += chunkSize) {
for (let i = 0; i < mods.length; i += chunkSize) {
futures.push(
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize),
username, token, factorioVersion)
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
);
}
await Promise.all(futures);
}
static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
/**
* Filter an array of mods based on if they are installed.
*
* @param mods - Array of mods to filer.
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
* @returns Filtered array of mods based on install state.
*/
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
const installedMods = Array.from(this.mods());
return mods.filter(mod => (
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
));
}
/**
@@ -332,4 +355,19 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
/**
* Fetch the full release list for a mod including dependencies.
*
* @param modName - Name of the mod to fetch the releases of
* @returns Mod portal response
*/
static async fetchModReleases(modName: string) {
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
return await response.json() as ModsInfoResponse["results"][number];
}
}
+3
View File
@@ -35,6 +35,7 @@ export interface ControllerConfigFields {
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -267,6 +268,7 @@ export interface HostConfigFields {
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -399,6 +401,7 @@ export interface InstanceConfigFields {
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
[key: `${string}.load_plugin`]: boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
+149 -17
View File
@@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox";
import * as libHash from "../hash";
import * as libSchema from "../schema";
import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
};
const lettersRegex = /^[a-zA-Z]+$/;
export class ModDependency {
public type: ModDependencyType;
public name: string;
public version: ModVersionEquality | undefined;
static getTypeFromPrefix(prefix: string): ModDependencyType {
switch (prefix) {
case "!":
return "incompatible";
case "?":
return "optional";
case "(?)":
return "hidden";
case "~":
return "unordered";
case "":
return "required";
default:
throw new Error(`Invalid dependency prefix: ${prefix}`);
}
}
constructor (
public specification: string,
) {
const parts = specification.split(" ");
// Fix mods which include spaces in their name, joins consecutive words together
let index = 0;
while (index < parts.length - 1) {
const current = parts[index];
const next = parts[index + 1];
if (lettersRegex.test(current) && lettersRegex.test(next)) {
parts[index] = `${current} ${next}`;
parts.splice(index + 1, 1);
} else {
index += 1;
}
}
// Parse the dependency specification
switch (parts.length) {
case 1:
this.type = "required";
this.name = parts[0];
break;
case 2:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
break;
case 3:
this.type = "required";
this.name = parts[0];
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
break;
case 4:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
break;
default:
throw new Error(`Invalid dependency specification: ${specification}`);
}
}
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
const mod = mods.find(m => m.name === this.name);
if (mod === undefined) {
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
} else if (this.type === "incompatible") {
return "incompatible";
} else if (this.version && !this.version.testVersion(mod.version)) {
return "wrong_version";
}
return undefined;
}
isSatisfied(mods: (ModInfo | ModRecord)[]) {
return this.checkUnsatisfiedReason(mods) === undefined;
}
get incompatible() {
return this.type === "incompatible";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
get optional() {
return this.type === "hidden" || this.type === "optional";
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.specification;
}
}
/**
* Info about a mod available on the controller.
@@ -35,13 +154,13 @@ export default class ModInfo {
* Version of the mod.
* Sourced from info.json.
*/
version = "";
version = "0.0.0" as FullVersion;
/**
* Integer representation of the version
*/
get integerVersion() {
return integerModVersion(this.version);
return integerFullVersion(this.version);
}
/**
@@ -78,21 +197,38 @@ export default class ModInfo {
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12";
factorioVersion = "0.12" as "0.12" | ApiVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
* Dependiences for this mod.
* Dependencies for this mod.
* Sourced from info.json.
*/
dependencies = ["base"];
dependencies = [new ModDependency("base")];
/**
* Dependencies for this mod.
* As they would be represented in info.json.
*/
get dependencySpecifications() {
return this.dependencies.map(d => d.specification);
}
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
return this.dependencies
.map(d => d.checkUnsatisfiedReason(mods))
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
), undefined);
}
/**
* Expected name of zip file containing this mod.
@@ -107,7 +243,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: string) {
static filename(name: string, version: FullVersion) {
return `${name}_${version}.zip`;
}
@@ -137,13 +273,13 @@ export default class ModInfo {
// Content of info.json found in mod files
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"title": Type.String(),
"author": Type.String(),
"contact": Type.Optional(Type.String()),
"homepage": Type.Optional(Type.String()),
"description": Type.Optional(Type.String()),
"factorio_version": Type.Optional(Type.String()),
"factorio_version": Type.Optional(ApiVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -172,7 +308,7 @@ export default class ModInfo {
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
// Additional data
if (json.size) { modInfo.size = json.size; }
@@ -195,8 +331,8 @@ export default class ModInfo {
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
json.dependencies = this.dependencies;
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
if (this.size) { json.size = this.size; }
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
@@ -227,10 +363,6 @@ export default class ModInfo {
throw new Error("Mod's info.json is not valid");
}
if (!modVersionRegExp.test(modInfo.version)) {
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
}
const stat = await fs.stat(modPath);
return this.fromJSON({
...modInfo,
+28 -17
View File
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import { integerFactorioVersion } from "./version";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
} from "./version";
export const ModSettingColor = Type.Object({
r: Type.Number(),
@@ -40,17 +46,21 @@ export interface ModRecord {
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod. */
version: string,
version: FullVersion,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
error?: "missing"|"bad_checksum",
error?: "missing" | "bad_checksum",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
info?: ModInfo,
}
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -82,13 +92,13 @@ export default class ModPack {
/**
* Version of Factorio this mod pack is for
*/
factorioVersion = "1.1.0";
factorioVersion = "1.1.0" as PartialVersion;
/**
* Integer representation of the factorioVersion
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
@@ -136,7 +146,7 @@ export default class ModPack {
"id": Type.Optional(Type.Integer()),
"name": Type.String(),
"description": Type.String(),
"factorio_version": Type.String(),
"factorio_version": PartialVersionSchema,
"mods": Type.Array(ModRecordJsonSchema),
"settings": Type.Object({
"startup": ModSettingsJsonSchema,
@@ -385,39 +395,40 @@ export default class ModPack {
/** Array of default mod packs which should exist on a newly installed cluster */
static defaultModPacks = [
...["0.17", "0.18", "1.0", "1.1", "2.0"]
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
factorio_version: version,
} as any)),
...["2.0"]
...(["2.0"] as const)
.map(version => this.fromJSON({
name: `Space Age ${version}`,
description: `Factorio ${version} with Space Age expansion.`,
factorio_version: version,
mods: [
{ name: "elevated-rails", enabled: true, version },
{ name: "quality", enabled: true, version },
{ name: "space-age", enabled: true, version },
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
];
/**
* Returns an array of ModInfo containing all mods included in factorio
* Returns an array of ModRecord containing all mods included in factorio
*
* @param factorioVersion - Factorio version to get the default mods for
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: string) {
static getBuiltinMods(factorioVersion: PartialVersion) {
factorioVersion = normaliseFullVersion(factorioVersion);
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: factorioVersion },
];
const integerVersion = integerFactorioVersion(factorioVersion);
if (integerVersion >= integerFactorioVersion("1.2")) {
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
@@ -428,7 +439,7 @@ export default class ModPack {
return defaultMods;
}
static getBuiltinModNames(factorioVersion: string) {
static getBuiltinModNames(factorioVersion: PartialVersion) {
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* @author Hornwitser
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModInfo, ModDependency } from "./ModInfo";
export { ModSettingColor } from "./ModPack";
export type { ModSetting, ModRecord } from "./ModPack";
export { default as ModPack } from "./ModPack";
+8 -7
View File
@@ -5,6 +5,7 @@ import { InstanceConfig } from "../config";
import type { IControllerUser } from "./User";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
export const InstanceStatus = StringEnum([
@@ -42,7 +43,7 @@ export class InstanceDetails {
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: string | undefined,
public factorioVersion: TargetVersion | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false,
@@ -57,7 +58,7 @@ export class InstanceDetails {
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(TargetVersionSchema),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
@@ -744,8 +745,8 @@ export class HostInstanceUpdate {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public status: InstanceStatus,
public gamePort: undefined | number,
public factorioVersion: undefined | string,
public gamePort: number | undefined,
public factorioVersion: PartialVersion | undefined,
) { }
static jsonSchema = Type.Object({
@@ -754,7 +755,7 @@ export class HostInstanceUpdate {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(PartialVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -852,7 +853,7 @@ export class InstanceStatusChangedEvent {
public instanceId: number,
public status: InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: TargetVersion,
) { }
static jsonSchema = Type.Object({
@@ -861,7 +862,7 @@ export class InstanceStatusChangedEvent {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Integer()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
+110 -30
View File
@@ -1,8 +1,14 @@
import { Type, Static } from "@sinclair/typebox";
import ModInfo from "./ModInfo";
import ModInfo, { ModDependency } from "./ModInfo";
import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
ModVersionEquality,
} from "./version";
export class ModPackGetRequest {
declare ["constructor"]: typeof ModPackGetRequest;
@@ -113,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -148,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: string,
public factorioVersion: ApiVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -157,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": Type.String(),
"factorioVersion": ApiVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -199,9 +205,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: Type.String(),
version: FullVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: Type.String() }),
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -229,20 +235,13 @@ export class ModPortalGetAllRequest {
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
// Define allowed Factorio versions
static allowedVersions = [
Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"),
Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"),
Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"),
] as const;
constructor(
public factorioVersion: Static<typeof ModPortalGetAllRequest.allowedVersions[number]>,
public factorioVersion: ApiVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
"factorioVersion": ApiVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -277,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -303,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -356,6 +355,16 @@ export class ModUpdatesEvent {
}
}
export interface ModNameVersionPair {
name: string,
version: ModVersionEquality,
}
export const ModNameVersionPairSchema = Type.Object({
"name": Type.String(),
"version": ModVersionEquality.jsonSchema,
});
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
@@ -363,8 +372,7 @@ export class ModUpdatesEvent {
* Requires Factorio credentials to be configured on the controller if the portal
* requires authentication for downloads.
*
* @param name - Name of the mod to download.
* @param version - Version of the mod to download.
* @param mods - Array of mods to be downloaded.
* @param factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
@@ -373,21 +381,93 @@ export class ModPortalDownloadRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = ModInfo;
static Response = jsonArray(ModInfo);
constructor(
public name: string,
public version: string,
public factorioVersion: string,
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
return new this(
json.mods.map(mod => ({
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
})),
json.factorioVersion,
);
}
}
/**
* Request mods dependencies to be resolved using the Factorio mod portal.
*
* @param mods - Array of dependencies to resolve.
* @param factorioVersion - Factorio version context for resolution.
*/
export class ModDependencyResolveRequest {
declare ["constructor"]: typeof ModDependencyResolveRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"checkForUpdates": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
}
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static Response = class ModDependencyResolveResponse {
constructor(
public dependencies: ModInfo[],
public incompatible: string[],
public missing: string[],
) {}
static jsonSchema = Type.Object({
"dependencies": Type.Array(ModInfo.jsonSchema),
"incompatible": Type.Array(Type.String()),
"missing": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
}
};
}
+224 -16
View File
@@ -1,33 +1,241 @@
// Helper functions for dealing with factorio version numbers
import { Static, Type } from "@sinclair/typebox";
/**
* Integer representation of a mod version
* Integer representation of a version string.
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
*/
export function integerModVersion(version: string) {
export type IntegerVersion = number & { [integerVersionSymbol]: void };
declare const integerVersionSymbol: unique symbol;
/**
* Allowed version equalities as defined within mod info json
* https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
*/
const versionEqualities = ["<", "<=", "=", ">=", ">"] as const;
export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v)));
export type VersionEquality = Static<typeof VersionEqualitySchema>;
export function isVersionEquality(input: string): input is VersionEquality {
return versionEqualities.includes(input as any);
}
/**
* Matches valid factorio versions accepts by the web API
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
*/
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
export type ApiVersion = Static<typeof ApiVersionSchema>;
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
}
export function normaliseApiVersion(version: PartialVersion) {
const parts = version.split(".");
const apiVersion = `${parts[0]}.${parts[1]}`;
if (!isApiVersion(apiVersion)) {
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
}
return apiVersion;
}
/**
* Matches valid mod versions, where all parts are specified.
*/
const fullVersionRegExp = /^\d+\.\d+\.\d+$/;
export type FullVersion = `${number}.${number}.${number}`;
export const FullVersionSchema = Type.Unsafe<FullVersion>(
Type.String({ pattern: fullVersionRegExp.source })
);
export function isFullVersion(input: string): input is FullVersion {
return fullVersionRegExp.test(input);
}
export function integerFullVersion(version: FullVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
// Can't use bitwise here because this is 48-bits.
return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion;
}
export function normaliseFullVersion(version: PartialVersion) {
const parts = version.split(".");
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Integer representation of a factorio version
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
*/
export function integerFactorioVersion(version: string) {
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
export type PartialVersion = FullVersion | `${number}.${number}`;
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
export function isPartialVersion(input: string): input is PartialVersion {
return partialVersionRegExp.test(input);
}
export function integerPartialVersion(version: PartialVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
// Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0.
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
}
/**
* Matches valid mod versions.
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
*/
export const modVersionRegExp = /^\d+\.\d+\.\d+$/;
export type TargetVersion = PartialVersion | "latest";
export const TargetVersionSchema = Type.Union([
PartialVersionSchema,
Type.Literal("latest"),
]);
export function isTargetVersion(input: string): input is TargetVersion {
return input === "latest" || partialVersionRegExp.test(input);
}
/**
* Represents a mod version paired with an equality which can be tested against
*/
export class ModVersionEquality {
public integerVersion: IntegerVersion;
constructor(
public equality: VersionEquality,
public version: PartialVersion,
) {
this.integerVersion = integerPartialVersion(version);
if (!isVersionEquality(this.equality)) {
throw new Error("Invalid equality");
}
if (!isPartialVersion(this.version)) {
throw new Error("Invalid version");
}
}
testIntegerVersion(other: IntegerVersion) {
switch (this.equality) {
case "<":
return other < this.integerVersion;
case "<=":
return other <= this.integerVersion;
case "=":
return other === this.integerVersion;
case ">=":
return other >= this.integerVersion;
case ">":
return other > this.integerVersion;
default:
throw new Error("unreachable");
}
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
toString() {
return `${this.equality} ${this.version}`;
}
static fromString(version: string) {
const parts = version.split(" ");
return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]);
}
static fromParts(equality: string, version: string) {
if (!isVersionEquality(equality)) {
throw new Error(`Unknown version equality: ${equality}`);
}
if (!isPartialVersion(version)) {
throw new Error(`Malformed version string: ${version}`);
}
return new this(equality, version);
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return this.fromString(json);
}
toJSON() {
return this.toString();
}
}
/**
* Represents a range of mod versions which can be tested against
*/
export class ModVersionRange {
constructor(
public minVersion = new ModVersionEquality(">=", "0.0.0"),
public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"),
) {
if (minVersion.equality === "<" || minVersion.equality === "<=") {
throw new Error("Minimum version can not use < or <=");
}
if (maxVersion.equality === ">" || maxVersion.equality === ">=") {
throw new Error("Maximum version can not use > or >=");
}
}
get valid() {
return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion)
&& this.maxVersion.testIntegerVersion(this.minVersion.integerVersion);
}
invalidate() {
this.maxVersion = new ModVersionEquality(">", "0.0.0");
}
testIntegerVersion(other: IntegerVersion) {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
combineVersion(other: ModVersionEquality) {
switch (other.equality) {
case "<":
if (other.integerVersion <= this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "<=":
if (other.integerVersion < this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "=":
if (this.testIntegerVersion(other.integerVersion)) {
this.minVersion = other;
this.maxVersion = other;
} else {
// Make invalid as it could not be satisfied
this.invalidate();
}
break;
case ">=":
if (other.integerVersion > this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
case ">":
if (other.integerVersion >= this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
default:
throw new Error("unreachable");
}
}
}
+1
View File
@@ -108,6 +108,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
mod.ModDependencyResolveRequest,
role.PermissionListRequest,
role.RoleListRequest,
+8 -2
View File
@@ -4,6 +4,7 @@
* @module lib/plugin_loader
*/
import path from "path";
import fs from "fs/promises";
import * as libErrors from "./errors";
import * as libPlugin from "./plugin";
import { logger } from "./logging";
@@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
// Check if plugin path exists, otherwise remove it
try {
require.resolve(pluginPath);
} catch (err) {
logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`);
} catch {
let errMsg = `Plugin path ${pluginPath} does not exist`;
try {
await fs.access(pluginPath, fs.constants.F_OK);
errMsg = `Plugin path ${pluginPath} missing index or main file`;
} catch {}
logger.error(`${errMsg}, not loading ${pluginName}`);
pluginList.delete(pluginName);
continue;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { RequestError } from "./errors";
import { PluginNodeEnvInfo } from "./plugin";
const execAsync = util.promisify(exec);
async function isDev() {
function isDev() {
// dev: <devRoot>/packages/lib/dist/src/rce_ops.js
// prod: <prodRoot>/node_modules/@clusterio/lib/dist/src/rce_ops.js
return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -34,9 +34,10 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.send(
@@ -37,9 +37,10 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.sendTo(
@@ -1,12 +1,14 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { message, Button, Table } from "antd";
import { message, Button, Table, Space } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import type { SizeType } from "antd/es/config-provider/SizeContext";
import type { ColumnsType } from "antd/es/table";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
import { useHosts } from "../model/host";
import { RestartRequired } from "./system_metrics";
import InstanceStatusTag from "./InstanceStatusTag";
import InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton";
import * as lib from "@clusterio/lib";
@@ -25,6 +27,7 @@ export default function InstanceList(props: InstanceListProps) {
let account = useAccount();
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
function hostName(hostId?: number) {
if (hostId === undefined) {
@@ -34,10 +37,10 @@ export default function InstanceList(props: InstanceListProps) {
}
function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) {
if (instance.factorioVersion === undefined) {
if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") {
return -1;
}
return lib.integerFactorioVersion(instance.factorioVersion);
return lib.integerPartialVersion(instance.factorioVersion);
}
let columns: ColumnsType<lib.InstanceDetails> = [
@@ -50,12 +53,15 @@ export default function InstanceList(props: InstanceListProps) {
{
title: "Assigned Host",
key: "assignedHost",
render: (_, instance) => <Link
to={`/hosts/${instance.assignedHost}/view`}
onClick={e => e.stopPropagation()}
>
{hostName(instance.assignedHost)}
</Link>,
render: (_, instance) => <Space>
<Link
to={`/hosts/${instance.assignedHost}/view`}
onClick={e => e.stopPropagation()}
>
{hostName(instance.assignedHost)}
</Link>
<RestartRequired system={instance.assignedHost ? systems.get(instance.assignedHost) : undefined}/>
</Space>,
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
responsive: ["sm"],
},
+43 -5
View File
@@ -1,6 +1,9 @@
import React, { Fragment } from "react";
import { Descriptions, Grid, Typography } from "antd";
import { Descriptions, Grid, Tooltip, Typography, Space } from "antd";
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined";
import * as lib from "@clusterio/lib";
@@ -8,11 +11,25 @@ const { useBreakpoint } = Grid;
type ModDetailsProps<T> = {
mod: T;
mods?: lib.ModRecord[];
actions?: (mod: T) => React.JSX.Element;
};
export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props: ModDetailsProps<T>) {
let screens = useBreakpoint();
const mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
let mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
if ("info" in mod) {
// Add the mod info if present in a mod record
mod = { ...mod, ...mod.info };
}
const depWarnings = new Map<string, lib.ModRecord["warning"]>();
if (mod.dependencies && props.mods) {
for (const dependency of mod.dependencies) {
depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods));
}
}
return <Descriptions
className="borderless"
@@ -52,7 +69,14 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
{Boolean(mod.homepage) && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
<Descriptions.Item label="Internal&nbsp;Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
{Boolean(mod.factorioVersion)
&& <Descriptions.Item label="Factorio&nbsp;Version">{mod.factorioVersion}</Descriptions.Item>
&& <Descriptions.Item label="Factorio&nbsp;Version">
<Space>
{mod.warning === "wrong_factorio_version" && <Tooltip title="Wrong factorio version.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.factorioVersion}
</Space>
</Descriptions.Item>
}
{Boolean(mod.filename) && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
{mod.size !== undefined
@@ -62,8 +86,22 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
{mod.dependencies && mod.dependencies.length
&& <Descriptions.Item label="Dependencies" span={2}>
{mod.dependencies
.filter(e => !e.startsWith("(?)"))
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
.filter(e => e.type !== "hidden")
.map((e, i) => <Fragment key={i}>
<Space>
{depWarnings.get(e.name) === "incompatible" && <Tooltip title="Incompatible mod added.">
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{depWarnings.get(e.name) === "missing_dependency" && <Tooltip title="Dependency missing.">
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{depWarnings.get(e.name) === "wrong_version" && <Tooltip title="Wrong version added.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{e.specification}
</Space>
<br/>
</Fragment>)
}
</Descriptions.Item>
}
@@ -1,19 +1,19 @@
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FieldData } from "rc-field-form/lib/interface";
import {
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
TablePaginationConfig,
TablePaginationConfig, Alert, notification,
} from "antd";
import { FieldData } from "rc-field-form/lib/interface";
import {
ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined,
CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined,
} from "@ant-design/icons";
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
import ExportOutlined from "@ant-design/icons/ExportOutlined";
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
import CloseOutlined from "@ant-design/icons/CloseOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
@@ -28,16 +28,20 @@ import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
const { logger } = lib;
const { Paragraph, Text } = Typography;
const { Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
type ModChange =
{
type: "name" | "description" | "factorioVersion",
type: "name" | "description"
name?: never,
scope?: never,
value: string,
} | {
type: "factorioVersion",
name?: never,
scope?: never,
value: lib.PartialVersion,
} | {
type: "settings.set" | "settings.delete",
name: string,
@@ -47,7 +51,7 @@ type ModChange =
type: "mods.set" | "mods.delete",
name: string,
scope?: never,
value: lib.ModRecord | lib.ModInfo,
value: lib.ModRecord,
}
;
@@ -71,28 +75,38 @@ function SearchModsTable(props: SearchModsTableProps) {
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
const [modResultCount, setModResultCount] = useState<number>(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join(".");
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
} catch (err) {
setFactorioVersion(null);
}
}, [props.modPack.factorioVersion]);
useEffect(() => {
let canceled = false;
control.send(new lib.ModSearchRequest(
searchText,
factorioVersion,
modResultPage,
modResultPageSize,
modResultSort,
modResultSortOrder,
)).then(response => {
if (canceled) {
return;
}
// In React < v18 this causes 3 renders and a partial state necessitating the use
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
setModResults(response.results);
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
setModResultCount(response.resultCount);
});
if (factorioVersion) {
control.send(new lib.ModSearchRequest(
searchText,
factorioVersion,
modResultPage,
modResultPageSize,
modResultSort,
modResultSortOrder,
)).then(response => {
if (canceled) {
return;
}
// In React < v18 this causes 3 renders and a partial state necessitating the use
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
setModResults(response.results);
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
setModResultCount(response.resultCount);
});
}
return () => {
canceled = true;
@@ -239,19 +253,260 @@ function SearchModsTable(props: SearchModsTableProps) {
</>;
}
type DownloadDependenciesProps = {
disabled?: boolean;
checkForUpdates?: boolean;
modPack: lib.ModPack;
mods: lib.ModRecord[];
onChange: (change: ModChange) => void;
builtInModNames: string[];
}
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
const control = useContext(ControlContext);
const [modInfos] = useMods();
// State for the modal and dependencies
const [open, setOpen] = useState(false);
const [missing, setMissing] = useState<string[]>([]);
const [builtins, setBuiltins] = useState<string[]>([]);
const [incompatible, setIncompatible] = useState<string[]>([]);
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
// State for all mods fetched from backend
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [allMods, setAllMods] = useState<lib.ModInfo[]>([]);
const [mods, setMods] = useState<lib.ModInfo[]>([]);
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
} catch (err) {
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
setFactorioVersion(null);
}
}, [props.modPack.factorioVersion]);
// Fetch all mods from backend when modal opens or version changes
useEffect(() => {
if (!open || !factorioVersion) {
// Clear if closed or no version
setMods([]);
setAllMods([]);
setMissing([]);
setBuiltins([]);
setIncompatible([]);
return;
}
setLoading(true);
setError(null);
let canceled = false;
control.send(
lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates)
).then(response => {
if (canceled) { return; }
setMods(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`)));
setIncompatible(response.incompatible);
setAllMods(response.dependencies);
setMissing(response.missing.filter(name => !props.builtInModNames.includes(name)));
setBuiltins(response.missing.filter(name => props.builtInModNames.includes(name)));
setLoading(false);
}).catch(err => {
if (canceled) { return; }
notifyErrorHandler("Error fetching mods dependencies")(err);
setError(err);
setMods([]);
setAllMods([]);
setMissing([]);
setBuiltins([]);
setIncompatible([]);
setLoading(false);
});
// eslint-disable-next-line consistent-return
return () => {
canceled = true;
};
// Re-fetch when modal opens or factorio version changes
}, [open, factorioVersion, control]);
// Function to handle download from Mod Portal to Controller
function handleControllerDownload() {
if (!factorioVersion || !mods) {
return;
}
// Enable any required builtins
for (const name of builtins) {
props.onChange({
type: "mods.set",
name: name,
value: { ...props.modPack.mods.get(name)!, enabled: true },
});
}
// Disable any incompatible mods present
for (const name of incompatible) {
const modRecord = props.modPack.mods.get(name);
if (modRecord) {
props.onChange({
type: "mods.set",
name: name,
value: { ...modRecord, enabled: false },
});
}
}
// Add all dependencies to the mod pack
for (const mod of allMods) {
props.onChange({
type: "mods.set",
name: mod.name,
value: {
name: mod.name,
version: mod.version,
sha1: mod.sha1,
enabled: true,
},
});
}
// Download the missing dependencies
control.send(
new lib.ModPortalDownloadRequest(
mods.map(mod => ({
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
})),
factorioVersion,
)
).then(() => {
notification.success({
message: "Download complete",
description: `${mods.length} mods have been downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
);
};
return <>
{props.checkForUpdates && <Button
disabled={props.disabled}
icon={<CloudSyncOutlined />}
onClick={() => { setOpen(true); }}
>Check for Updates</Button> || <Button
disabled={props.disabled}
icon={<CloudDownloadOutlined />}
onClick={() => { setOpen(true); }}
>Download Dependencies</Button>}
<Modal
title={props.checkForUpdates ? "Download Updates" : "Download Dependencies"}
open={open}
onCancel={() => { setOpen(false); }}
width={1000}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
<Button
key="start"
type="primary"
disabled={loading || mods.length === 0}
onClick={() => { setOpen(false); handleControllerDownload(); }}
>Start Download</Button>,
]}
>
{/* Display error message if fetching failed */}
{error && <Alert
message="Error Fetching Mods"
description={error.message}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>}
{/* Display warning message if dependencies could not be found */}
{!loading && missing.length > 0 && <Alert
message="Failed to Find Dependencies:"
description={<ul>
{missing.map(name => <li>{name}</li>)}
</ul>}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>}
{/* Display warning message if dependencies are incompatible */}
{!loading && incompatible.length > 0 && <Alert
message="Incompatible Dependencies Found:"
description={<ul>
{incompatible.map(name => <li>{name}</li>)}
</ul>}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>}
<Table
dataSource={mods}
rowKey={record => record.name}
loading={loading}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
showTotal: (total: number) => (
props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies`
),
}}
columns={[
{
title: "Name",
dataIndex: "name",
key: "name",
sorter: true,
},
{
title: "Title",
dataIndex: "title",
key: "title",
sorter: true,
},
{
title: "Author",
dataIndex: "author",
key: "author",
sorter: true,
},
{
title: "Selected Version",
dataIndex: "version",
key: "version",
},
]}
/>
</Modal>
</>;
}
type ModsTableProps = {
modPack: lib.ModPack;
changes: ModChange[];
onChange: (change: ModChange) => void;
onRevert: (change: ModChange) => void;
builtInMods: string[];
builtInModNames: string[];
};
function ModsTable(props: ModsTableProps) {
const [showAddMods, setShowAddMods] = useState(false);
const [modsMap] = useMods();
const account = useAccount();
const [modInfos] = useMods();
const deletedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
const changedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
const [showAddMods, setShowAddMods] = useState(false);
const deletedMods: Map<string, lib.ModRecord> = new Map();
const changedMods: Map<string, lib.ModRecord> = new Map();
for (let change of props.changes) {
if (change.name) {
if (props.modPack.mods.has(change.name)) {
@@ -265,34 +520,119 @@ function ModsTable(props: ModsTableProps) {
}
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
(mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => {
if (props.builtInMods.includes(mod.name)) {
(mod: lib.ModRecord): lib.ModRecord => {
if (props.builtInModNames.includes(mod.name)) {
return {
...mod,
enabled: (mod as any).enabled ?? false,
enabled: mod.enabled ?? false,
};
}
const candidate = modsMap.get(`${mod.name}_${mod.version}`);
if (!candidate) {
const modInfo = modInfos.get(`${mod.name}_${mod.version}`);
if (!modInfo) {
return {
...mod,
enabled: (mod as any).enabled ?? false,
enabled: mod.enabled ?? false,
error: "missing",
};
} else if (mod.sha1 && candidate.sha1 !== mod.sha1) {
} else if (mod.sha1 && modInfo.sha1 !== mod.sha1) {
return {
...mod,
enabled: (mod as any).enabled ?? false,
enabled: mod.enabled ?? false,
error: "bad_checksum",
};
}
return candidate;
return {
...mod,
enabled: mod.enabled ?? false,
info: modInfo,
};
}
);
function actions(mod: lib.ModRecord|lib.ModInfo) {
for (const mod of mods) {
if (mod.enabled && mod.info) {
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
try {
const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion);
const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion);
if (packFactorioVersion !== modFactorioVersion) {
mod.warning = "wrong_factorio_version";
}
} catch {
// Hit if the mod or mod pack version is not a valid api version
mod.warning = "wrong_factorio_version";
}
}
}
async function fixDependencyIssues(mod: lib.ModRecord) {
if (!mod.info) {
throw new Error("Mod record has no info"); // Should be unreachable
}
if (mod.info.factorioVersion === "0.12") {
throw new Error("Factorio version 0.12 not supported by mod portal");
}
let hasFailure = false;
const enabledMods = mods.filter(m => m.enabled);
const modInfoArray = [...modInfos.values()];
for (const dependency of mod.info.dependencies) {
const reason = dependency.checkUnsatisfiedReason(enabledMods);
if (reason === undefined) {
continue; // No issues for this dependency
}
if (reason === "incompatible") {
// Disable the incompatible mod
props.onChange({
type: "mods.set",
name: dependency.name,
value: { ...props.modPack.mods.get(dependency.name)!, enabled: false },
});
continue;
}
if (props.builtInModNames.includes(dependency.name)) {
// If builtin mod then enable it
props.onChange({
type: "mods.set",
name: dependency.name,
value: { ...props.modPack.mods.get(dependency.name)!, enabled: true },
});
continue;
}
// Find a suitable version to add
const candidate = modInfoArray
.filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion
&& (!dependency.version || dependency.version.testVersion(info.version))
))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
if (candidate) {
props.onChange({
type: "mods.set",
name: candidate.name,
value: {
name: candidate.name,
version: candidate.version,
sha1: candidate.sha1,
enabled: true,
},
});
} else {
hasFailure = true;
}
}
return hasFailure;
}
function actions(mod: lib.ModRecord) {
return <Space>
{!deletedMods.has(mod.name) && !props.builtInMods.includes(mod.name) && <Typography.Link
{!deletedMods.has(mod.name) && !props.builtInModNames.includes(mod.name) && <Typography.Link
type="danger"
onClick={() => {
props.onChange({
@@ -311,6 +651,13 @@ function ModsTable(props: ModsTableProps) {
});
}}
>revert</Typography.Link>}
{(mod.warning && mod.warning !== "wrong_factorio_version") && <Typography.Link
onClick={async () => {
if (await fixDependencyIssues(mod)) {
notify("Failed to automatically fix all issues", "warning");
}
}}
>fix issues</Typography.Link>}
</Space>;
}
@@ -318,6 +665,38 @@ function ModsTable(props: ModsTableProps) {
<SectionHeader
title="Mods"
extra={<Space>
<Button
icon={<ToolOutlined />}
disabled={!mods.some(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")}
onClick={async () => {
const hasFailure = await Promise.all(mods
.filter(m => m.enabled && m.warning && m.warning !== "wrong_factorio_version")
.map(m => fixDependencyIssues(m))
);
if (hasFailure.some(v => v)) {
notify("Failed to automatically fix all issues", "warning");
}
}}
>Fix Issues</Button>
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
&& <DownloadDependenciesButton
mods={mods}
modPack={props.modPack}
onChange={props.onChange}
builtInModNames={props.builtInModNames}
disabled={!mods.some(
m => m.enabled
&& (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning))
)}
/>}
{account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal")
&& <DownloadDependenciesButton
mods={mods}
modPack={props.modPack}
onChange={props.onChange}
builtInModNames={props.builtInModNames}
checkForUpdates
/>}
<Button icon={<PlusOutlined />} onClick={() => setShowAddMods(true)}>Add</Button>
</Space>}
/>
@@ -361,14 +740,26 @@ function ModsTable(props: ModsTableProps) {
{
title: "Name",
key: "name",
render: (_, mod: any) => <>
render: (_, mod: lib.ModRecord) => <>
{mod.error === "missing" && <Tooltip title="Mod is missing from storage.">
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum missmatch.">
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum mismatch.">
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.title || mod.name}
{mod.warning === "incompatible" && <Tooltip title="Mod is incompatible with another.">
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "missing_dependency" && <Tooltip title="Mod is missing a dependency.">
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_version" && <Tooltip title="Mod has wrong dependency version added.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_factorio_version" && <Tooltip title="Mod has wrong factorio version.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.info?.title || mod.name}
</>,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
@@ -386,7 +777,7 @@ function ModsTable(props: ModsTableProps) {
},
]}
expandable={{
expandedRowRender: mod => <ModDetails mod={mod} actions={actions} />,
expandedRowRender: mod => <ModDetails mod={mod} mods={mods.filter(m => m.enabled)} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={mods}
@@ -908,9 +1299,7 @@ export default function ModPackViewPage() {
changes={changes}
onChange={pushChange}
onRevert={revertChange}
builtInMods={
lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion).map(builtinMod => builtinMod.name)
}
builtInModNames={lib.ModPack.getBuiltinModNames(modifiedModPack.factorioVersion)}
/>
<SettingsTable
modPack={modifiedModPack}
+10 -18
View File
@@ -6,7 +6,6 @@ import {
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
} from "@ant-design/icons";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
@@ -25,9 +24,6 @@ import UploadButton from "./UploadButton";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
// Define the allowed Factorio versions based on the lib schema
type FactorioVersion = Static<(typeof lib.ModPortalGetAllRequest)["allowedVersions"][number]>;
// Type alias for the mod structure derived from the library response
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
@@ -145,7 +141,7 @@ function SearchModsButton() {
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<FactorioVersion>("2.0");
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
@@ -162,22 +158,21 @@ function SearchModsButton() {
const handleControllerDownload = (
modName: string,
modTitle: string | undefined,
modVersion: string | undefined,
portalFactorioVersion: FactorioVersion,
modVersion: lib.FullVersion | undefined,
portalFactorioVersion: lib.ApiVersion,
) => {
if (modVersion) {
control.send(
new lib.ModPortalDownloadRequest(
modName,
modVersion,
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
portalFactorioVersion
)
).then(() => {
notification.success({
message: "Download started",
message: "Download complete",
description: `${modTitle || modName} v${
modVersion
} is being downloaded to the controller.`,
} has been downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
@@ -270,7 +265,7 @@ function SearchModsButton() {
// Update search text and factorio version from form
const handleSearch = (changedValues: any, allValues: any) => {
const nameValue = allValues.name;
const versionValue = allValues.factorioVersion as FactorioVersion | undefined;
const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined;
// Only trigger state updates if values actually changed
if (nameValue !== searchText) {
@@ -351,12 +346,9 @@ function SearchModsButton() {
label="Factorio Version"
>
<Select>
{lib.ModPortalGetAllRequest.allowedVersions.map((literalSchema: any) => (
<Select.Option
key={literalSchema.const}
value={literalSchema.const}
>
{literalSchema.const}
{lib.ApiVersions.map(version => (
<Select.Option key={version} value={version}>
{version}
</Select.Option>
)).reverse()}
</Select>
+1 -1
View File
@@ -187,7 +187,7 @@ export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: bo
{
formAction !== undefined
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
&& <Form.Item label="Restart on completion" name="restart">
&& <Form.Item label="Restart on completion" name="restart" valuePropName="checked">
<Checkbox disabled={props.canRestart === false}/>
</Form.Item>
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
+134
View File
@@ -0,0 +1,134 @@
/**
* Publish any mods that need to be published
*/
"use strict";
const FormData = require("form-data");
const fs = require("fs-extra");
const yargs = require("yargs");
let DRY = false;
const modReleaseCache = new Map();
/**
* Gets a list of all releases for a mod
*/
async function requestModReleases(mod) {
const cache = modReleaseCache.get(mod);
if (cache) {
return cache;
}
// eslint-disable-next-line no-console
console.log(`Requesting current releases of ${mod}`);
const response = await fetch(`https://mods.factorio.com/api/mods/${mod}`);
if (!response.ok) {
throw new Error(`Failed request to mod portal api: ${response.statusText}\n${await response.text()}`);
}
const json = await response.json();
modReleaseCache.set(mod, json.releases);
return json.releases;
}
/**
* Upload a mod to the mod portal
*/
async function uploadModRelease(file, name) {
// eslint-disable-next-line no-console
console.log(`Uploading new release ${file}`);
if (DRY) {
return;
}
const headers = {
Authorization: `Bearer ${process.env.FACTORIO_TOKEN}`,
};
// Start the upload process by requesting an upload url
const initFormData = new FormData();
initFormData.append("mod", name);
const initResponse = await fetch("https://mods.factorio.com/api/v2/mods/releases/init_upload", {
method: "POST",
headers: headers,
body: initFormData,
});
if (!initResponse.ok) {
throw new Error(`Failed init mod upload: ${initResponse.statusText}\n${await initResponse.text()}`);
}
// Upload the new version of the mod
const uploadUrl = (await initResponse.json()).upload_url;
const uploadFormData = new FormData();
uploadFormData.append("file", fs.createReadStream(file));
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
headers: headers,
body: uploadFormData,
});
if (!uploadResponse.ok) {
throw new Error(`Failed mod upload: ${uploadResponse.statusText}\n${await uploadResponse.text()}`);
}
}
const regexModZip = /(\w+)_\d+.\d+.\d+\.zip/;
/**
* Publishes all mods that require publishing
*/
async function publishMods(dir) {
const files = await fs.readdir(dir);
const mods = files
.map(file => regexModZip.exec(file))
.filter(file => Boolean(file));
const uploading = [];
for (const mod of mods) {
const [file, name] = mod;
const releases = await requestModReleases(name);
if (!releases.some(release => release.file_name === file)) {
uploading.push(uploadModRelease(file, name));
} else {
// eslint-disable-next-line no-console
console.log(`Skipped ${file}`);
}
}
await Promise.all(uploading);
}
/**
* Main function for this script
*/
async function main() {
await yargs(process.argv.slice(2))
.scriptName("publish")
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not publish anything",
default: false,
type: "boolean",
})
.command(["factorio <mods-dir>", "$0 <mods-dir>"], "Publish factorio mods", yargs => {
yargs
.positional("mods-dir", {
describe: "Path to packed mods to publish",
normalize: true,
type: "string",
})
.demandOption("mods-dir", "A path to the mods dir must be specified");
}, async argv => {
DRY = argv.dry;
publishMods(argv.modsDir);
})
.strict()
.parse();
}
// Run main if started from command line
if (module === require.main) {
main();
}
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const assert = require("assert").strict;
/**
* Generate a flat array of tests from a matrix of inputs.
*
* @template {any[][]} T
* @param {T} arrays - A list of arrays representing the different values for each argument
* @returns {Array<{ [K in keyof T]: T[K][number] }>}
* An array of tuples, each containing one value from each input array.
*/
function testMatrix(...arrays) {
return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]);
}
/**
* Test that a class is round trip json serialisable across multiple test cases.
*
* @template {any[]} T - Constructor arguments
* @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods.
* @param {T[]} tests - The tests inputs to pass to the class constructor.
*/
function testRoundTripJsonSerialisable(Class, tests) {
for (const test of tests) {
const original = new Class(...test);
const serialised = JSON.stringify(original);
const reconstructed = Class.fromJSON(JSON.parse(serialised));
assert.deepEqual(reconstructed, original, JSON.stringify(test));
}
}
module.exports = {
testMatrix,
testRoundTripJsonSerialisable,
};
+107 -2
View File
@@ -1,9 +1,14 @@
"use strict";
const path = require("node:path");
const assert = require("assert").strict;
const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller");
const { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role } = require("@clusterio/lib");
const { EventEmitter } = require("stream");
const {
ControllerConfig, Address, RequestError,
InstanceConfig, SystemInfo, Role, ModPack,
} = require("@clusterio/lib");
class MockEvent {}
class MockInstanceConfig extends EventEmitter {
@@ -27,8 +32,10 @@ describe("controller/src/Controller", function() {
describe("class Controller", function() {
/** @type {Controller} */
let controller, mockInstanceConfig;
const controllerVersion = require("@clusterio/controller/package.json").version;
before(async function() {
controller = new Controller({}, [], new ControllerConfig("controller"));
const controllerConfig = new ControllerConfig("controller", { "controller.version": controllerVersion });
controller = new Controller({}, [], controllerConfig);
mockInstanceConfig = new MockInstanceConfig(new Map([
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
]));
@@ -233,6 +240,62 @@ describe("controller/src/Controller", function() {
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
});
describe(".checkRestartRequired()", function() {
beforeEach(function() {
// Would not be needed if controller setup was beforeEach
controller.config.restartRequired = false;
controller.config.set("controller.version", controllerVersion);
controller.config.set("global_chat.load_plugin", true);
controller.pluginInfos[0] = {
name: "global_chat",
requirePath: path.dirname(require.resolve("@clusterio/controller/package.json")),
version: controllerVersion,
};
});
it("returns false when no changes are present", async function() {
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns false when an unloaded plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
controller.config.set("global_chat.load_plugin", false);
controller.config.restartRequired = false; // Setting load_plugin requires a restart
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns true when the config flag is set", async function() {
controller.config.restartRequired = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when clusterio version changes", async function() {
controller.config.set("controller.version", "0.0.0");
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a controller plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a web plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
});
});
describe("migrations", function() {
describe("SystemInfo", function() {
@@ -314,6 +377,48 @@ describe("controller/src/Controller", function() {
]);
});
});
describe("ModPack", function() {
it("migrates builtin mod versions to X.Y.Z", function() {
const modPacks = Controller.migrateModPacks([{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
assert.deepEqual(modPacks, [{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
});
it("does nothing for upto date data", function() {
const modPack = ModPack.fromJSON({
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
});
const jsonString = JSON.stringify(modPack);
const modPackJson = JSON.parse(jsonString);
const modPackJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateModPacks([
modPackJson, modPackJson, modPackJson,
]);
assert.deepEqual(result, [
modPackJsonCopy, modPackJsonCopy, modPackJsonCopy,
]);
});
});
describe("Role", function() {
it("migrates renamed permissions", function() { // Alpha 17
const result = Controller.migrateRoles([
+59
View File
@@ -6,6 +6,7 @@ const lib = require("@clusterio/lib");
const Host = require("@clusterio/host/dist/node/src/Host").default;
const Instance = require("@clusterio/host/dist/node/src/Instance").default;
const { _discoverInstances } = require("@clusterio/host/dist/node/src/Host");
const { HostConnector } = require("@clusterio/host/dist/node/host");
describe("Host testing", function() {
describe("discoverInstances()", function() {
@@ -115,5 +116,63 @@ describe("Host testing", function() {
]);
});
});
describe(".checkRestartRequired()", function() {
let host;
beforeEach(function() {
// This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent
const hostVersion = require("@clusterio/host/package.json").version;
const pluginInfos = [{
name: "global_chat",
requirePath: path.dirname(require.resolve("@clusterio/host/package.json")),
version: hostVersion,
}];
const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion });
const hostConnector = new HostConnector(hostConfig, undefined, pluginInfos);
host = new Host(hostConnector, hostConfig, undefined, pluginInfos);
});
it("returns false when no changes are present", async function() {
host.pluginInfos[0].hostEntrypoint = true;
host.pluginInfos[0].instanceEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, false);
assert.equal(host.config.restartRequired, false);
});
it("returns false when an unloaded plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].hostEntrypoint = true;
host.pluginInfos[0].instanceEntrypoint = true;
host.config.set("global_chat.load_plugin", false);
host.config.restartRequired = false; // Setting load_plugin requires a restart
const result = await host.checkRestartRequired();
assert.equal(result, false);
assert.equal(host.config.restartRequired, false);
});
it("returns true when the config flag is set", async function() {
host.config.restartRequired = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when clusterio version changes", async function() {
host.config.set("host.version", "0.0.0");
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when a host plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].hostEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
it("returns true when an instance plugin version changes", async function() {
host.pluginInfos[0].version = "0.0.0";
host.pluginInfos[0].instanceEntrypoint = true;
const result = await host.checkRestartRequired();
assert.equal(result, true);
assert.equal(host.config.restartRequired, true);
});
});
});
});
+1 -1
View File
@@ -951,7 +951,7 @@ describe("Integration of Clusterio", function() {
await execCtl("instance start 44");
const instance = (await getInstances()).get(44);
const isV2 = lib.integerFactorioVersion(instance.factorioVersion) > lib.integerFactorioVersion("2.0.0");
const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0");
const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, "");
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
await execCtl("instance stop 44");
+12 -3
View File
@@ -117,15 +117,14 @@ class TestHostConnector extends lib.WebSocketClientConnector {
}
}
// Mark that this test takes a lot of time, or depeneds on a test
// that takes a lot of time.
// Mark that this test takes a lot of time, or depends on a test that takes a lot of time.
function slowTest(test) {
if (process.env.FAST_TEST) {
test.skip();
}
test.timeout(20000);
test.timeout(30000);
}
async function get(urlPath) {
@@ -196,7 +195,9 @@ async function execCtlProcess(...args) {
return await exec(...args);
}
let inExecCtl = false;
async function execCtl(command) {
inExecCtl = true;
process.chdir("temp/test");
try {
const initArgs = await initializeCtl(command, control.plugins, true);
@@ -209,9 +210,17 @@ async function execCtl(command) {
await targetCommand.run(initArgs.args, control);
} finally {
process.chdir("../..");
inExecCtl = false;
}
}
afterEach(function() {
if (inExecCtl) {
// This is needed in case a test times out and never runs the finally block
process.chdir("../..");
}
});
async function sendRcon(instanceId, command) {
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
}
+108 -102
View File
@@ -7,7 +7,7 @@ const JSZip = require("jszip"); // Added for creating mock zips
// Capture native fetch before any potential mocks are applied
const nativeFetch = global.fetch;
const { ModStore, ModInfo } = require("@clusterio/lib"); // Adjust path based on compiled output
const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output
const MODS_DIR = path.join("temp", "test", "mod_store", "mods");
const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json");
@@ -148,7 +148,7 @@ describe("lib/ModStore", function () {
assert.equal(modInfo.name, modName);
assert.equal(modInfo.version, modVersion);
// Check if the dependency was updated (indicating re-read)
assert(modInfo.dependencies.includes("? updated-dep"));
assert(modInfo.dependencySpecifications.includes("? updated-dep"));
});
it("should handle corrupted zip files gracefully", async function () {
@@ -271,7 +271,7 @@ describe("lib/ModStore", function () {
});
// Helper to create the mock fetch function for the Factorio Mod Portal API
function createMockPortalApiFetch(modsToDownloadMap, fetchCallsArray = null) {
function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) {
return async (url, options) => {
const urlString = url.toString();
if (fetchCallsArray) {
@@ -285,11 +285,12 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownloadMap.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
// Use assert or throw for clearer test failure messages
assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -309,7 +310,7 @@ describe("lib/ModStore", function () {
}
it("should download and add a single mod", async function () {
const modsToDownload = new Map([["download-mod-1", "1.1.0"]]);
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
@@ -327,10 +328,10 @@ describe("lib/ModStore", function () {
});
it("should download multiple mods", async function () {
const modsToDownload = new Map([
["multi-mod-a", "1.0.0"],
["multi-mod-b", "2.0.0"],
]);
const modsToDownload = [
{ name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") },
{ name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") },
];
// Define fetch mock specific to this test
global.fetch = createMockPortalApiFetch(modsToDownload);
@@ -343,43 +344,34 @@ describe("lib/ModStore", function () {
assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip")));
assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip")));
});
it("should throw when no releases available", async function () {
const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }];
it("should skip already downloaded mods", async function () {
const existingModName = "existing-mod";
const existingModVersion = "1.0.0";
const existingFilename = `${existingModName}_${existingModVersion}.zip`;
await createMockModZip(path.join(MODS_DIR, existingFilename), existingModName, existingModVersion);
modStore = await ModStore.fromDirectory(MODS_DIR);
assert.equal(modStore.files.size, 1);
// Define fetch mock specific to this test
global.fetch = async (url, options) => {
const urlString = url.toString();
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => ({ name: name }));
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
// Fallback for unhandled fetches in this test
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
const modsToDownload = new Map([
[existingModName, existingModVersion], // Already present
["new-mod", "0.5.0"], // New mod
]);
let fetchCalls = [];
// Define fetch mock specific to this test (wraps original for tracking)
global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls);
await modStore.downloadMods(modsToDownload, username, token, factorioVersion);
assert.equal(modStore.files.size, 2, "Should have 2 mods total");
assert(modStore.files.has(existingFilename));
assert(modStore.files.has("new-mod_0.5.0.zip"));
assert(await fs.pathExists(path.join(MODS_DIR, "new-mod_0.5.0.zip")));
const postCall = fetchCalls.find(call => call.options?.method === "POST");
assert(postCall, "POST call to API should have happened");
const requestedMods = (postCall.options.body.get("namelist") || "").split(",");
assert.deepEqual(requestedMods, ["new-mod"], "Only new-mod should be in the namelist");
await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion));
});
it("should skip built-in mods", async function () {
const modsToDownload = new Map([
["base", "1.1.0"], // Built-in
["core", "1.1.0"], // Built-in
["another-new-mod", "1.0.0"], // Not built-in
]);
const modsToDownload = [
{ name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in
{ name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in
];
let fetchCalls = [];
// Define fetch mock specific to this test
@@ -396,51 +388,8 @@ describe("lib/ModStore", function () {
assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist");
});
it("should handle Factorio version formatting (e.g., 1.1.100 -> 1.1)", async function () {
const modsToDownload = new Map([["version-test-mod", "1.0.0"]]);
let capturedApiUrl = null;
// Define fetch mock specific to this test
const testFetch = async (url, options) => {
const urlString = url.toString();
if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") {
capturedApiUrl = urlString; // Capture URL
const body = options.body;
let requestedModNames = [];
if (body instanceof URLSearchParams) {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
return {
name: name,
latest_release: {
download_url: `/download/${name}/${requestedVersion}`,
file_name: `${name}_${requestedVersion}.zip`,
info_json: { factorio_version: factorioVersion },
version: requestedVersion,
sha1: `sha1_${name}_${requestedVersion}`,
},
};
});
return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) };
}
return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` };
};
global.fetch = testFetch;
await modStore.downloadMods(modsToDownload, username, token, "1.1.100"); // Use full version
assert(capturedApiUrl, "Fetch to API mods should have been called");
const apiUrlParams = new URL(capturedApiUrl).searchParams;
assert.equal(apiUrlParams.get("version"), "1.1", "Factorio version should be truncated in API call");
});
it("should handle API error during mod info fetch", async function () {
const modsToDownload = new Map([["api-error-mod", "1.0.0"]]);
const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }];
// Define fetch mock specific to this test (simulates API error)
global.fetch = async (url, options) => {
@@ -463,11 +412,11 @@ describe("lib/ModStore", function () {
});
it("should handle download error for a specific mod", async function () {
const modsToDownload = new Map([
["good-mod", "1.0.0"], // This one should succeed
["bad-download-mod", "1.0.0"], // This one will fail
["another-good-mod", "1.0.0"], // This one should also succeed
]);
const modsToDownload = [
{ name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
{ name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail
{ name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success
];
// Mock ModStore.downloadFile to fail for the specific mod
const originalDSF = ModStore.downloadFile;
@@ -496,10 +445,11 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -526,12 +476,12 @@ describe("lib/ModStore", function () {
});
it("should download mods in chunks", async function () {
const modsToDownload = new Map([
["chunk-mod-1", "1.0.0"],
["chunk-mod-2", "1.0.0"],
["chunk-mod-3", "1.0.0"],
["chunk-mod-4", "1.0.0"],
]);
const modsToDownload = [
{ name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") },
{ name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") },
];
let postCalls = 0;
// Define fetch mock specific to this test
@@ -545,10 +495,11 @@ describe("lib/ModStore", function () {
requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean);
}
const results = requestedModNames.map(name => {
const requestedVersion = modsToDownload.get(name);
if (!requestedVersion) {
const requestedMod = modsToDownload.find(mod => mod.name === name);
if (!requestedMod) {
throw new Error(`[Test Mock ERROR] No version for ${name}`);
}
const requestedVersion = requestedMod.version.version;
return {
name: name,
latest_release: {
@@ -570,7 +521,7 @@ describe("lib/ModStore", function () {
// Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call
assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods");
assert.equal(modStore.files.size, modsToDownload.size, "All chunked mods should be downloaded");
assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded");
assert(modStore.files.has("chunk-mod-1_1.0.0.zip"));
assert(modStore.files.has("chunk-mod-4_1.0.0.zip"));
});
@@ -843,6 +794,40 @@ describe("lib/ModStore", function () {
});
});
describe("fetchModReleases (static)", function() {
// Keep track of the original fetch for the live test
let originalFetch;
before(function() {
originalFetch = global.fetch;
});
after(function() {
global.fetch = originalFetch; // Restore original fetch after this suite
});
it("should fetch from the correct api url (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) };
};
const result = await ModStore.fetchModReleases("my-mod");
assert.equal(result.name, "my-mod");
});
it("should throw an error when the fetch fails (mock)", async function() {
global.fetch = async function(url) {
assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full");
return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) };
};
await assert.rejects(ModStore.fetchModReleases("my-mod"));
});
it("should fetch from the correct api url (live)", async function() {
global.fetch = nativeFetch;
const result = await ModStore.fetchModReleases("clusterio_lib");
assert(result.releases && result.releases.length > 1, "Failed to fetch releases");
});
});
// === Instance Methods ===
describe("Instance Methods", function () {
let modStore;
@@ -1004,5 +989,26 @@ describe("lib/ModStore", function () {
assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo");
});
});
describe("filterInstalled", function() {
it("should filter out installed mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, false), [
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
]);
});
it("should filter out uninstalled mods", function() {
const mods = [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
{ name: "not-installed", version: ModVersionEquality.fromString("1.0.0") },
];
assert.deepEqual(modStore.filterInstalled(mods, true), [
{ name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) },
]);
});
});
});
});
+245 -2
View File
@@ -5,10 +5,226 @@ const path = require("path");
const lib = require("@clusterio/lib");
const libBuildMod = require("@clusterio/lib/build_mod");
const { ModInfo } = lib;
const { ModInfo, ModDependency } = lib;
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/ModInfo", function() {
describe("class ModDependency", function() {
describe(".getTypeFromPrefix()", function() {
it("should not throw for valid prefixes", function() {
assert.equal(ModDependency.getTypeFromPrefix("!"), "incompatible");
assert.equal(ModDependency.getTypeFromPrefix("?"), "optional");
assert.equal(ModDependency.getTypeFromPrefix("(?)"), "hidden");
assert.equal(ModDependency.getTypeFromPrefix("~"), "unordered");
assert.equal(ModDependency.getTypeFromPrefix(""), "required");
});
it("should throw for unknown prefixes", function() {
assert.throws(() => ModDependency.getTypeFromPrefix("invalid"));
});
});
describe("constructor", function() {
it("should accept specifications of name only", function() {
const dependency = new ModDependency("my-mod");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "required");
assert.equal(dependency.version, undefined);
});
it("should accept specifications of name and prefix", function() {
const dependency = new ModDependency("? my-mod");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "optional");
assert.equal(dependency.version, undefined);
});
it("should accept specifications of name and version", function() {
const dependency = new ModDependency("my-mod >= 1.2.3");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "required");
assert.notEqual(dependency.version, undefined);
assert.equal(dependency.version.equality, ">=");
assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3"));
});
it("should accept specifications of name, prefix and version", function() {
const dependency = new ModDependency("? my-mod >= 1.2.3");
assert.equal(dependency.name, "my-mod");
assert.equal(dependency.type, "optional");
assert.notEqual(dependency.version, undefined);
assert.equal(dependency.version.equality, ">=");
assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3"));
});
it("should throw if no version equality is given", function() {
assert.throws(() => new ModDependency("my-mod 1.2.3"));
assert.throws(() => new ModDependency("? my-mod 1.2.3"));
});
it("should throw if invalid specification given", function() {
assert.throws(() => new ModDependency("? ? my-mod = 1.2.3"));
});
it("should throw if name contains spaces", function() {
// Apparently there are some legacy mods which include spaces
// Such as https://mods.factorio.com/mod/Explosive%20Excavation
this.skip();
assert.throws(() => new ModDependency("my mod"));
assert.throws(() => new ModDependency("? my mod"));
assert.throws(() => new ModDependency("? my mod >= 1.2.3"));
});
it("should not error when the name contains spaces", function() {
const dependencyReq = new ModDependency("my mod");
assert.equal(dependencyReq.name, "my mod");
assert.equal(dependencyReq.type, "required");
const dependencyOpt = new ModDependency("? my mod");
assert.equal(dependencyOpt.name, "my mod");
assert.equal(dependencyOpt.type, "optional");
const dependencyVer = new ModDependency("my mod >= 1.2.3");
assert.equal(dependencyVer.name, "my mod");
assert.equal(dependencyVer.type, "required");
assert.equal(dependencyVer.version.version, "1.2.3");
const dependencyOptVer = new ModDependency("? my mod >= 1.2.3");
assert.equal(dependencyOptVer.name, "my mod");
assert.equal(dependencyOptVer.type, "optional");
assert.equal(dependencyOptVer.version.version, "1.2.3");
});
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(ModDependency, testMatrix(
["my-mod", "? my-mod", "! my-mod", "my-mod > 1.0.0", "~ my-mod <= 2.0.0"], // Specification
));
});
});
describe("checkUnsatisfiedReason()", function() {
const mods = [
ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }),
];
it("should be undefined for incompatible being missing", function() {
const dependency = new ModDependency("! not-present");
assert.equal(dependency.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for incompatible being present", function() {
const dependency = new ModDependency("! my-mod");
assert.equal(dependency.checkUnsatisfiedReason(mods), "incompatible");
});
it("should undefined for optional / hidden being missing", function() {
const dependencyOptional = new ModDependency("? not-present");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("(?) not-present");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should undefined for optional / hidden being present", function() {
const dependencyOptional = new ModDependency("? my-mod");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("(?) my-mod");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for optional / hidden being present but wrong version", function() {
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version");
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version");
});
it("should undefined for unordered / required being present", function() {
const dependencyOptional = new ModDependency("~ my-mod");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined);
const dependencyHidden = new ModDependency("my-mod");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined);
});
it("should give reason for unordered / required being present but wrong version", function() {
const dependencyOptional = new ModDependency("~ my-mod > 2.0.0");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version");
const dependencyHidden = new ModDependency("my-mod > 2.0.0");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version");
});
it("should give reason for unordered / required being missing", function() {
const dependencyOptional = new ModDependency("~ not-present");
assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "missing_dependency");
const dependencyHidden = new ModDependency("not-present");
assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "missing_dependency");
});
});
describe("isSatisfied()", function() {
const mods = [
ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }),
];
it("should pass for incompatible being missing", function() {
const dependency = new ModDependency("! not-present");
assert.equal(dependency.isSatisfied(mods), true);
});
it("should fail for incompatible being present", function() {
const dependency = new ModDependency("! my-mod");
assert.equal(dependency.isSatisfied(mods), false);
});
it("should pass for optional / hidden being missing", function() {
const dependencyOptional = new ModDependency("? not-present");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) not-present");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should pass for optional / hidden being present", function() {
const dependencyOptional = new ModDependency("? my-mod");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("(?) my-mod");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should fail for optional / hidden being present but wrong version", function() {
const dependencyOptional = new ModDependency("? my-mod > 2.0.0");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
it("should pass for unordered / required being present", function() {
const dependencyOptional = new ModDependency("~ my-mod");
assert.equal(dependencyOptional.isSatisfied(mods), true);
const dependencyHidden = new ModDependency("my-mod");
assert.equal(dependencyHidden.isSatisfied(mods), true);
});
it("should fail for unordered / required being present but wrong version", function() {
const dependencyOptional = new ModDependency("~ my-mod > 2.0.0");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("my-mod > 2.0.0");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
it("should fail for unordered / required being missing", function() {
const dependencyOptional = new ModDependency("~ not-present");
assert.equal(dependencyOptional.isSatisfied(mods), false);
const dependencyHidden = new ModDependency("not-present");
assert.equal(dependencyHidden.isSatisfied(mods), false);
});
});
describe("incompatible", function() {
it("should return true iff the type is incompatible", function() {
const dependencyIncompatible = new ModDependency("! my-mod");
assert.equal(dependencyIncompatible.incompatible, true);
for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.incompatible, false, test);
}
});
});
describe("required", function() {
it("should return true iff the dependency is required", function() {
for (const test of ["my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, true, test);
}
for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.required, false, test);
}
});
});
describe("optional", function() {
it("should return true iff the dependency is optional", function() {
for (const test of ["? my-mod", "(?) my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, true, test);
}
for (const test of ["! my-mod", "my-mod", "~ my-mod"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.optional, false, test);
}
});
});
});
describe("class ModInfo", function() {
it("should round trip serialize", function() {
const validate = lib.compile(ModInfo.jsonSchema);
@@ -70,7 +286,16 @@ describe("lib/data/ModInfo", function() {
factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion);
assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions);
});
it("should provide the original string specifications for dependencies", function() {
const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] });
assert.deepEqual(mod.dependencySpecifications, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"]);
});
it("should parse the provided dependency specifications", function() {
const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] });
assert.deepEqual(mod.dependencies,
["UltraMod", "SuperLib >= 1.00", "! bad-mod"].map(dep => new ModDependency(dep))
);
});
it("should load from a mod zip file", async function() {
await libBuildMod.build({
build: true,
@@ -100,5 +325,23 @@ describe("lib/data/ModInfo", function() {
})
);
});
describe("checkDependencySatisfaction()", function() {
it("should return undefined when satisfied", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "1.0.0" }]), undefined);
});
it("should return incompatible when there is an incompatibility", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "a", version: "1.0.0" }]), "incompatible");
});
it("should return missing dependency when missing but no incompatibly", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "c", version: "1.0.0" }]), "missing_dependency");
});
it("should return wrong version when wrong but not missing or incompatible", function() {
const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] });
assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "2.0.0" }]), "wrong_version");
});
});
});
});
+5 -5
View File
@@ -397,16 +397,16 @@ describe("lib/data/ModPack", function() {
it("should work with versions before 2.0", function() {
const builtinMods = ModPack.getBuiltinMods("1.1");
assert.deepEqual(builtinMods, [
{ name: "base", enabled: true, version: "1.1" },
{ name: "base", enabled: true, version: "1.1.0" },
]);
});
it("should work with versions after 2.0", function() {
const builtinMods = ModPack.getBuiltinMods("2.0");
assert.deepEqual(builtinMods, [
{ name: "base", enabled: true, version: "2.0" },
{ name: "elevated-rails", enabled: false, version: "2.0" },
{ name: "quality", enabled: false, version: "2.0" },
{ name: "space-age", enabled: false, version: "2.0" },
{ name: "base", enabled: true, version: "2.0.0" },
{ name: "elevated-rails", enabled: false, version: "2.0.0" },
{ name: "quality", enabled: false, version: "2.0.0" },
{ name: "space-age", enabled: false, version: "2.0.0" },
]);
});
});
+515
View File
@@ -0,0 +1,515 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { testMatrix, testRoundTripJsonSerialisable } = require("../../common");
describe("lib/data/version", function() {
describe("isVersionEquality()", function() {
it("should correctly validate input strings", function() {
const valid = ["<", "<=", "=", ">=", ">"];
for (const test of valid) {
assert.equal(lib.isVersionEquality(test), true, test);
}
const invalid = ["==", "!=", "<>", ""];
for (const test of invalid) {
assert.equal(lib.isVersionEquality(test), false, test);
}
});
});
describe("isApiVersion()", function() {
it("should correctly validate input strings", function() {
const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"];
for (const test of valid) {
assert.equal(lib.isApiVersion(test), true, test);
}
const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"];
for (const test of invalid) {
assert.equal(lib.isApiVersion(test), false, test);
}
});
});
describe("normaliseApiVersion()", function() {
it("should normalise accepted versions", function() {
assert.equal(lib.normaliseApiVersion("0.13"), "0.13");
assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13");
assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13");
assert.equal(lib.normaliseApiVersion("1.1"), "1.1");
assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1");
assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1");
assert.equal(lib.normaliseApiVersion("2.0"), "2.0");
assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0");
assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0");
assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0");
});
it("should throw for invalid versions", function() {
const invalid = ["0.12", "0", "", "latest"];
for (const test of invalid) {
assert.throws(() => lib.normaliseApiVersion(test), undefined, test);
}
});
});
describe("isFullVersion()", function() {
it("should correctly validate input strings", function() {
const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"];
for (const test of valid) {
assert.equal(lib.isFullVersion(test), true, test);
}
const invalid = ["0.12", "0.19", "1.0", "1.0.0.0", "0", "", "latest"];
for (const test of invalid) {
assert.equal(lib.isFullVersion(test), false, test);
}
});
});
describe("integerFullVersion()", function() {
it("should sort versions lexicographically", function() {
let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"];
unsortedVersions.sort((a, b) => lib.integerFullVersion(a) - lib.integerFullVersion(b));
assert.deepEqual(unsortedVersions, sortedVersions);
});
});
describe("normaliseFullVersion()", function() {
it("should normalise accepted versions", function() {
assert.equal(lib.normaliseFullVersion("0.13"), "0.13.0");
assert.equal(lib.normaliseFullVersion("0.13.0"), "0.13.0");
assert.equal(lib.normaliseFullVersion("0.13.1"), "0.13.1");
assert.equal(lib.normaliseFullVersion("1.1"), "1.1.0");
assert.equal(lib.normaliseFullVersion("1.1.0"), "1.1.0");
assert.equal(lib.normaliseFullVersion("1.1.1"), "1.1.1");
assert.equal(lib.normaliseFullVersion("2.0"), "2.0.0");
assert.equal(lib.normaliseFullVersion("2.0.0"), "2.0.0");
assert.equal(lib.normaliseFullVersion("2.0.1"), "2.0.1");
});
});
describe("isPartialVersion()", function() {
it("should correctly validate input strings", function() {
const valid = [
"0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0",
"0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110",
];
for (const test of valid) {
assert.equal(lib.isPartialVersion(test), true, test);
}
const invalid = ["1.0.0.0", "0", "", "latest"];
for (const test of invalid) {
assert.equal(lib.isPartialVersion(test), false, test);
}
});
});
describe("integerPartialVersion()", function() {
it("should sort versions lexicographically", function() {
let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"];
let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"];
unsortedVersions.sort((a, b) => lib.integerPartialVersion(a) - lib.integerPartialVersion(b));
assert.deepEqual(unsortedVersions, sortedVersions);
});
});
describe("isTargetVersion()", function() {
it("should correctly validate input strings", function() {
const valid = [
"0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0",
"0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110", "latest",
];
for (const test of valid) {
assert.equal(lib.isTargetVersion(test), true, test);
}
const invalid = ["1.0.0.0", "0", ""];
for (const test of invalid) {
assert.equal(lib.isTargetVersion(test), false, test);
}
});
});
describe("class ModVersionEquality", function() {
const tests = testMatrix(
["<", "<=", "=", ">=", ">"],
["0.18", "1.0", "1.0.0", "1.1.1"],
);
describe("constructor", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.ModVersionEquality, tests);
});
it("should be constructible", function() {
for (const [eq, ver] of tests) {
const test = `${eq} ${ver}`;
const version = new lib.ModVersionEquality(eq, ver);
assert.equal(version.equality, eq, test);
assert.equal(version.version, ver, test);
assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test);
}
});
it("should throw when given an invalid equality", function() {
assert.throws(() => new lib.ModVersionEquality("==", "1.0.0"));
});
it("should throw when given an invalid version", function() {
assert.throws(() => new lib.ModVersionEquality("=", "1.0.0.0"));
});
});
describe("testIntegerVersion()", function() {
it("should test for less than", function() {
const version = new lib.ModVersionEquality("<", "0.0.100");
assert.equal(version.testIntegerVersion(90), true);
assert.equal(version.testIntegerVersion(100), false);
assert.equal(version.testIntegerVersion(110), false);
});
it("should test for less than equal", function() {
const version = new lib.ModVersionEquality("<=", "0.0.100");
assert.equal(version.testIntegerVersion(90), true);
assert.equal(version.testIntegerVersion(100), true);
assert.equal(version.testIntegerVersion(110), false);
});
it("should test for equal", function() {
const version = new lib.ModVersionEquality("=", "0.0.100");
assert.equal(version.testIntegerVersion(90), false);
assert.equal(version.testIntegerVersion(100), true);
assert.equal(version.testIntegerVersion(110), false);
});
it("should test for greater than equal", function() {
const version = new lib.ModVersionEquality(">=", "0.0.100");
assert.equal(version.testIntegerVersion(90), false);
assert.equal(version.testIntegerVersion(100), true);
assert.equal(version.testIntegerVersion(110), true);
});
it("should test for greater than", function() {
const version = new lib.ModVersionEquality(">", "0.0.100");
assert.equal(version.testIntegerVersion(90), false);
assert.equal(version.testIntegerVersion(100), false);
assert.equal(version.testIntegerVersion(110), true);
});
it("should throw if the equality is invalid (unreachable)", function() {
const version = new lib.ModVersionEquality(">", "0.0.100");
version.equality = "==";
assert.throws(() => version.testIntegerVersion(100));
});
});
describe("testVersion()", function() {
it("should test for less than", function() {
const version = new lib.ModVersionEquality("<", "1.1.1");
assert.equal(version.testVersion("1.0"), true);
assert.equal(version.testVersion("1.1"), true);
assert.equal(version.testVersion("1.1.1"), false);
assert.equal(version.testVersion("1.1.2"), false);
assert.equal(version.testVersion("1.2"), false);
});
it("should test for less than equal", function() {
const version = new lib.ModVersionEquality("<=", "1.1.1");
assert.equal(version.testVersion("1.0"), true);
assert.equal(version.testVersion("1.1"), true);
assert.equal(version.testVersion("1.1.1"), true);
assert.equal(version.testVersion("1.1.2"), false);
assert.equal(version.testVersion("1.2"), false);
});
it("should test for equal", function() {
const version = new lib.ModVersionEquality("=", "1.1.1");
assert.equal(version.testVersion("1.0"), false);
assert.equal(version.testVersion("1.1"), false);
assert.equal(version.testVersion("1.1.1"), true);
assert.equal(version.testVersion("1.1.2"), false);
assert.equal(version.testVersion("1.2"), false);
});
it("should test for greater than equal", function() {
const version = new lib.ModVersionEquality(">=", "1.1.1");
assert.equal(version.testVersion("1.0"), false);
assert.equal(version.testVersion("1.1"), false);
assert.equal(version.testVersion("1.1.1"), true);
assert.equal(version.testVersion("1.1.2"), true);
assert.equal(version.testVersion("1.2"), true);
});
it("should test for greater than", function() {
const version = new lib.ModVersionEquality(">", "1.1.1");
assert.equal(version.testVersion("1.0"), false);
assert.equal(version.testVersion("1.1"), false);
assert.equal(version.testVersion("1.1.1"), false);
assert.equal(version.testVersion("1.1.2"), true);
assert.equal(version.testVersion("1.2"), true);
});
it("should throw if the equality is invalid (unreachable)", function() {
const version = new lib.ModVersionEquality(">", "1.1.1");
version.equality = "==";
assert.throws(() => version.testInteger("1.0.0"));
});
});
describe("toString()", function() {
it("should be convertible to string", function() {
for (const [eq, ver] of tests) {
const expected = `${eq} ${ver}`;
const version = new lib.ModVersionEquality(eq, ver);
assert.equal(version.toString(), expected);
}
});
});
describe("fromString()", function() {
it("should be constructable from string", function() {
for (const [eq, ver] of tests) {
const test = `${eq} ${ver}`;
const version = lib.ModVersionEquality.fromString(test);
assert.equal(version.equality, eq, test);
assert.equal(version.version, ver, test);
assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test);
}
});
it("should error for invalid equalities", function() {
const equalities = ["==", "!=", "<>", ""];
for (const eq of equalities) {
const test = `${eq} 1.0.0`;
assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test);
}
});
it("should error for invalid versions", function() {
const versions = ["1.0.0.0", "0", "", "latest"];
for (const ver of versions) {
const test = `= ${ver}`;
assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test);
}
});
});
describe("fromParts()", function() {
it("should be constructable from parts", function() {
for (const [eq, ver] of tests) {
const test = `${eq} ${ver}`;
const version = lib.ModVersionEquality.fromParts(eq, ver);
assert.equal(version.equality, eq, test);
assert.equal(version.version, ver, test);
assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test);
}
});
it("should error for invalid equalities", function() {
const equalities = ["==", "!=", "<>", ""];
for (const eq of equalities) {
assert.throws(() => lib.ModVersionEquality.fromParts(eq, "1.0.0"), undefined, eq);
}
});
it("should error for invalid versions", function() {
const versions = ["1.0.0.0", "0", "", "latest"];
for (const ver of versions) {
assert.throws(() => lib.ModVersionEquality.fromParts("=", ver), undefined, ver);
}
});
});
});
describe("class ModVersionRange", function() {
describe("constructor", function() {
it("should be constructable", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.minVersion.equality, ">=");
assert.equal(range.minVersion.version, "1.0.0");
assert.equal(range.maxVersion.equality, "<");
assert.equal(range.maxVersion.version, "2.0.0");
});
it("should be default constructable", function() {
const range = new lib.ModVersionRange();
assert.equal(range.minVersion.equality, ">=");
assert.equal(range.minVersion.version, "0.0.0");
assert.equal(range.maxVersion.equality, "<=");
assert.equal(range.maxVersion.version, "65535.65535.65535");
});
it("should throw if invalid equality is given for min version", function() {
assert.throws(() => new lib.ModVersionRange(
new lib.ModVersionEquality("<", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
));
assert.throws(() => new lib.ModVersionRange(
new lib.ModVersionEquality("<=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
));
});
it("should throw if invalid equality is given for max version", function() {
assert.throws(() => new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality(">", "2.0.0"),
));
assert.throws(() => new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality(">=", "2.0.0"),
));
});
});
describe("valid", function() {
it("should be true for valid ranges", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.valid, true);
const defaultRange = new lib.ModVersionRange();
assert.equal(defaultRange.valid, true);
});
it("should be false for invalid ranges", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "2.0.0"),
new lib.ModVersionEquality("<", "1.0.0"),
);
assert.equal(range.valid, false);
const rangeImpossible = new lib.ModVersionRange(
new lib.ModVersionEquality("=", "1.0.0"),
new lib.ModVersionEquality("=", "2.0.0"),
);
assert.equal(rangeImpossible.valid, false);
});
});
describe("invalidate()", function() {
it("should result in the range becoming invalid", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
range.invalidate();
assert.equal(range.valid, false);
const defaultRange = new lib.ModVersionRange();
defaultRange.invalidate();
assert.equal(defaultRange.valid, false);
});
});
describe("testIntegerVersion()", function() {
it("should return true when in range", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "0.0.100"),
new lib.ModVersionEquality("<", "0.0.200"),
);
assert.equal(range.testIntegerVersion(100), true);
assert.equal(range.testIntegerVersion(199), true);
});
it("should return false when out of range", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "0.0.100"),
new lib.ModVersionEquality("<", "0.0.200"),
);
assert.equal(range.testIntegerVersion(99), false);
assert.equal(range.testIntegerVersion(200), false);
});
it("should handle exact matches", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality("=", "0.0.100"),
new lib.ModVersionEquality("=", "0.0.100"),
);
assert.equal(range.testIntegerVersion(99), false);
assert.equal(range.testIntegerVersion(100), true);
assert.equal(range.testIntegerVersion(101), false);
});
});
describe("testVersion()", function() {
it("should return true when in range", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.0.0"), true);
assert.equal(range.testVersion("1.9.9"), true);
});
it("should return false when out of range", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("0.9.9"), false);
assert.equal(range.testVersion("2.0.0"), false);
});
it("should handle exact matches", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality("=", "1.0.0"),
new lib.ModVersionEquality("=", "1.0.0"),
);
assert.equal(range.testVersion("0.9.9"), false);
assert.equal(range.testVersion("1.0.0"), true);
assert.equal(range.testVersion("1.0.1"), false);
});
});
describe("combineVersion()", function() {
it("should handle merge with less than", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.1"), true);
assert.equal(range.testVersion("1.5"), true);
range.combineVersion(new lib.ModVersionEquality("<", "1.2"));
assert.equal(range.testVersion("1.1"), true);
assert.equal(range.testVersion("1.5"), false);
assert.equal(range.valid, true);
range.combineVersion(new lib.ModVersionEquality("<", "0.5"));
assert.equal(range.valid, false);
});
it("should handle merge with less than equal", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.2"), true);
assert.equal(range.testVersion("1.5"), true);
range.combineVersion(new lib.ModVersionEquality("<=", "1.2"));
assert.equal(range.testVersion("1.2"), true);
assert.equal(range.testVersion("1.5"), false);
assert.equal(range.valid, true);
range.combineVersion(new lib.ModVersionEquality("<=", "0.5"));
assert.equal(range.valid, false);
});
it("should handle merge with equal", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.2"), true);
assert.equal(range.testVersion("1.5"), true);
range.combineVersion(new lib.ModVersionEquality("=", "1.2"));
assert.equal(range.testVersion("1.2"), true);
assert.equal(range.testVersion("1.5"), false);
assert.equal(range.valid, true);
range.combineVersion(new lib.ModVersionEquality("=", "0.5"));
assert.equal(range.valid, false);
});
it("should handle merge with greater than equal", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.1"), true);
assert.equal(range.testVersion("1.5"), true);
range.combineVersion(new lib.ModVersionEquality(">=", "1.2"));
assert.equal(range.testVersion("1.1"), false);
assert.equal(range.testVersion("1.5"), true);
assert.equal(range.valid, true);
range.combineVersion(new lib.ModVersionEquality(">=", "2.5"));
assert.equal(range.valid, false);
});
it("should handle merge with greater than", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
assert.equal(range.testVersion("1.2"), true);
assert.equal(range.testVersion("1.5"), true);
range.combineVersion(new lib.ModVersionEquality(">", "1.2"));
assert.equal(range.testVersion("1.2"), false);
assert.equal(range.testVersion("1.5"), true);
assert.equal(range.valid, true);
range.combineVersion(new lib.ModVersionEquality(">", "2.5"));
assert.equal(range.valid, false);
});
it("should throw if the equality is invalid (unreachable)", function() {
const range = new lib.ModVersionRange(
new lib.ModVersionEquality(">=", "1.0.0"),
new lib.ModVersionEquality("<", "2.0.0"),
);
const version = new lib.ModVersionEquality(">", "1.2");
version.equality = "==";
assert.throws(() => range.combineVersion(version));
});
});
});
});
+4 -3
View File
@@ -5,7 +5,6 @@ const path = require("path");
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
const { escapeRegExp } = lib;
describe("lib/plugin_loader", function() {
@@ -34,7 +33,10 @@ describe("lib/plugin_loader", function() {
});
it("should ignore missing plugins", async function() {
const result = await lib.loadPluginInfos(new Map([["missing", missingPlugin]]), []);
const result = await lib.loadPluginInfos(new Map([
["missing", path.resolve(missingPlugin)],
["no_index", path.resolve(baseDir)],
]), []);
assert.deepEqual(result, []);
});
it("should load test plugin", async function() {
@@ -46,7 +48,6 @@ describe("lib/plugin_loader", function() {
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
require(path.resolve(brokenPlugin));
} catch (err) {
brokenMessage = err.message;
+462
View File
@@ -0,0 +1,462 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib;
const { testMatrix, testRoundTripJsonSerialisable } = require("../common");
const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller");
const { slowTest } = require("../integration");
describe("messages/mod", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let controlConnection;
/** @type {Map<string, lib.ModInfo>} */
let ModReleases = new Map();
/**
* @param {string} name
* @param {string} version
* @param {string} factorioVersion
* @param {string[]} dependencies
*/
function setPortalModRelease(name, version, factorioVersion, dependencies) {
ModReleases.set(name, {
name: name,
releases: [{
version: version,
info_json: {
factorio_version: factorioVersion,
dependencies: ["base", ...dependencies],
},
}],
});
}
const _ModStore_fetchModReleases = lib.ModStore.fetchModReleases;
after(function() {
lib.ModStore.fetchModReleases = _ModStore_fetchModReleases;
});
beforeEach(function() {
ModReleases = new Map();
lib.ModStore.fetchModReleases = function(modName) {
const mod = ModReleases.get(modName);
if (mod) {
return mod;
}
throw new Error("Mock 404");
};
const controllerConfig = new lib.ControllerConfig("controller");
const connection = new lib.VirtualConnector(
lib.Address.fromShorthand("controller"),
lib.Address.fromShorthand({ controlId: 1 }),
);
controller = new Controller(lib.logger, [], controllerConfig);
const user = new ControllerUser(controller.userManager, undefined, "test");
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
});
describe("ModDependencyResolveRequest", function() {
describe("static", function() {
const factorioVersions = ["1.0", "1.1", "2.0"];
const tests = testMatrix(
[ // mods
[],
[new ModDependency("foo")],
[new ModDependency("? foo"), new ModDependency("bar > 2.0.5")],
],
factorioVersions, // factorioVersion
[undefined, true, false],
);
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(ModDependencyResolveRequest, tests);
});
it("should be construable", function() {
for (const [mods, factorioVersion, checkForUpdates] of tests) {
const request = new ModDependencyResolveRequest(mods, factorioVersion, checkForUpdates);
assert.equal(request.factorioVersion, factorioVersion);
assert.equal(request.checkForUpdates, checkForUpdates ?? false);
assert.equal(request.mods, mods);
}
});
it("should be constructed from a ModPack", function() {
for (const factorioVersion of factorioVersions) {
const mods = [
{ name: "foo", version: "1.0.0", enabled: true },
{ name: "bar", version: "2.0.0", enabled: false },
];
const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion });
const request = ModDependencyResolveRequest.fromModPack(modPack);
assert.equal(request.factorioVersion, factorioVersion, factorioVersion);
assert.equal(request.mods.length, modPack.mods.size, factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "bar"), new ModDependency("bar = 2.0.0"), factorioVersion);
}
});
it("should be constructed from a ModPack (enabled only)", function() {
for (const factorioVersion of factorioVersions) {
const mods = [
{ name: "foo", version: "1.0.0", enabled: true },
{ name: "bar", version: "2.0.0", enabled: false },
];
const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion });
const request = ModDependencyResolveRequest.fromModPackEnabled(modPack);
assert.equal(request.factorioVersion, factorioVersion);
assert.equal(
request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "bar"), undefined, factorioVersion);
}
});
it("should be constructed from a ModPack (check for updates)", function() {
for (const factorioVersion of factorioVersions) {
const mods = [
{ name: "foo", version: "1.0.0", enabled: true },
{ name: "bar", version: "2.0.0", enabled: false },
];
const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion });
const request = ModDependencyResolveRequest.fromModPack(modPack, true);
assert.equal(request.factorioVersion, factorioVersion, factorioVersion);
assert.equal(request.mods.length, modPack.mods.size, factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "bar"), new ModDependency("bar >= 2.0.0"), factorioVersion);
}
});
it("should be constructed from a ModPack (enabled only) (check for updates)", function() {
for (const factorioVersion of factorioVersions) {
const mods = [
{ name: "foo", version: "1.0.0", enabled: true },
{ name: "bar", version: "2.0.0", enabled: false },
];
const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion });
const request = ModDependencyResolveRequest.fromModPackEnabled(modPack, true);
assert.equal(request.factorioVersion, factorioVersion);
assert.equal(
request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion);
assert.deepEqual(
request.mods.find(m => m.name === "bar"), undefined, factorioVersion);
}
});
});
describe("response", function() {
const tests = testMatrix(
[ // dependencies
[],
[
ModInfo.fromJSON({ name: "foo", version: "1.0.0" }),
],
[
ModInfo.fromJSON({ name: "foo", version: "1.0.0" }),
ModInfo.fromJSON({ name: "bar", version: "2.0.0" }),
],
],
[[], ["foo-i"], ["foo-i", "bar-i"]], // incompatible
[[], ["foo-m"], ["foo-m", "bar-m"]], // missing
);
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(ModDependencyResolveRequest.Response, tests);
});
it("should be construable", function() {
for (const [dependencies, incompatible, missing] of tests) {
const test = JSON.stringify([dependencies, missing, incompatible]);
const request = new ModDependencyResolveRequest.Response(dependencies, incompatible, missing);
assert.deepEqual(request.dependencies, dependencies, test);
assert.deepEqual(request.incompatible, incompatible, test);
assert.deepEqual(request.missing, missing, test);
}
});
});
describe("handle", function() {
const factorioVersions = ["1.0", "1.1", "2.0"];
it("resolves dependencies", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]);
setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []);
setPortalModRelease("bar", "1.0.0", factorioVersion, []);
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
setPortalModRelease("root-2", "1.0.0", factorioVersion, ["foo", "bar-2"]);
setPortalModRelease("bar-2", "1.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest(
[new ModDependency("root"), new ModDependency("root-2")],
factorioVersion,
)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, []);
const expectedIds = [
"root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0", "bar_1.0.0",
"root-2_1.0.0", "bar-2_1.0.0",
];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("highlights dependencies with no suitable version", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["bar < 2.0.0"]);
setPortalModRelease("bar", "2.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base", "bar"]);
assert.deepEqual(result.incompatible, []);
assert.equal(result.dependencies.length, 1);
assert.equal(result.dependencies[0].id, "root_1.0.0");
}
});
it("highlights dependencies with no releases", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base", "foo"]);
assert.deepEqual(result.incompatible, []);
assert.equal(result.dependencies.length, 1);
assert.equal(result.dependencies[0].id, "root_1.0.0");
}
});
it("highlights dependencies which are incompatible", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "! foo-dep"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]);
setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, ["foo-dep"]);
const expectedIds = ["root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("highlights dependencies with conflicting versions", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["dep > 2.0.0"]);
setPortalModRelease("bar", "1.0.0", factorioVersion, ["dep < 1.5.0"]);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, ["dep"]);
const expectedIds = ["root_1.0.0", "foo_1.0.0", "bar_1.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("highlights incompatible builtin mods", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["! base"]);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, []);
assert.deepEqual(result.incompatible, ["base"]);
const expectedIds = ["root_1.0.0", "foo_1.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("does not ignore optional dependencies when required by another", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "? baz"]);
setPortalModRelease("foo", "1.0.0", factorioVersion, ["baz"]);
setPortalModRelease("baz", "1.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, []);
const expectedIds = ["root_1.0.0", "foo_1.0.0", "baz_1.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("prefers the locally installed version if matching", async function() {
controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" }));
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]);
setPortalModRelease("foo", "2.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, []);
const expectedIds = ["root_1.0.0", "foo_1.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("prefers the mod portal version when checking for updates", async function() {
controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" }));
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]);
setPortalModRelease("foo", "2.0.0", factorioVersion, []);
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion, true)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, []);
const expectedIds = ["root_1.0.0", "foo_2.0.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("prefers the latest mod portal version when using the mod portal", async function() {
for (const factorioVersion of factorioVersions) {
setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0"]);
ModReleases.set("foo", {
name: "foo",
releases: [{
version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] },
}, {
version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] },
}],
});
ModReleases.set("bar", {
name: "bar",
releases: [{
version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] },
}, {
version: "1.5.0", info_json: { factorio_version: factorioVersion, dependencies: [] },
}, {
version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] },
}],
});
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, []);
const expectedIds = ["root_1.0.0", "foo_2.0.0", "bar_1.5.0"];
const depIds = new Set(result.dependencies.map(mod => mod.id));
assert.equal(depIds.size, expectedIds.length);
for (const modId of expectedIds) {
assert(depIds.has(modId), `Missing ${modId}`);
}
}
});
it("rejects when a network error occurs", async function() {
lib.ModStore.fetchModReleases = function() {
throw new Error("Mock network error");
};
for (const factorioVersion of factorioVersions) {
await assert.rejects(controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion)
));
}
});
it("resolves dependencies (live)", async function() {
this.timeout(60000); // Increase timeout to 60 seconds for live API call
lib.ModStore.fetchModReleases = _ModStore_fetchModReleases;
const result = await controlConnection.handleModDependencyResolveRequest(
new lib.ModDependencyResolveRequest([new ModDependency("pymodpack = 3.0.0")], "2.0")
);
assert.deepEqual(result.missing, ["base"]);
assert.deepEqual(result.incompatible, ["space-age"]);
const expectedNames = [ // Can't use ids as the versions may change
"pymodpack", "pypostprocessing", "pyalternativeenergy", "pyalternativeenergygraphics",
"pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3",
"pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics",
"pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics",
"pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics",
];
const depNames = new Set(result.dependencies.map(mod => mod.name));
assert.equal(depNames.size, expectedNames.length);
for (const modName of expectedNames) {
assert(depNames.has(modName), `Missing ${modName}`);
}
});
});
});
});