Add Factorio version management and download functionality

Co-authored-by: danielv <danielv@danielv.no>
This commit is contained in:
Cursor Agentanddanielv committed 2025-07-21 18:47:19 +00:00
1 parent 02d14bd98d
commit f6424c5411
4 files changed
+209

No files matched your search

+157
View File
@@ -6,6 +6,7 @@ import setBlocking from "set-blocking";
import phin from "phin";
import stream from "stream";
import util from "util";
import child_process from "child_process";
// internal libraries
import * as lib from "@clusterio/lib";
@@ -384,6 +385,8 @@ export default class Host extends lib.Link {
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
this.handle(lib.HostFactorioVersionsRequest, this.handleHostFactorioVersionsRequest.bind(this));
this.handle(lib.HostDownloadFactorioRequest, this.handleHostDownloadFactorioRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
@@ -859,6 +862,160 @@ export default class Host extends lib.Link {
return { results };
}
async handleHostFactorioVersionsRequest() {
const factorioDir = this.config.get("host.factorio_directory");
const versions: string[] = [];
try {
// Check if factorioDir is a simple installation
const simpleVersion = await this.getFactorioVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
versions.push(simpleVersion);
} else {
// Check for multiple versions in subdirectories
const entries = await fs.readdir(factorioDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const version = await this.getFactorioVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version !== null) {
versions.push(version);
}
}
}
} catch (error: any) {
if (error.code !== "ENOENT") {
logger.warn(`Error reading Factorio directory: ${error.message}`);
}
}
// Sort versions in descending order (newest first)
versions.sort((a, b) => {
const aParts = a.split(".").map(s => parseInt(s, 10));
const bParts = b.split(".").map(s => parseInt(s, 10));
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0;
const bPart = bParts[i] || 0;
if (aPart !== bPart) {
return bPart - aPart;
}
}
return 0;
});
return new lib.HostFactorioVersionsRequest.Response(versions);
}
async handleHostDownloadFactorioRequest() {
try {
const factorioDir = this.config.get("host.factorio_directory");
// Fetch the latest version info
const res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const location = res.headers.get("location");
if (!location) {
throw new Error("No redirect location found in response headers");
}
const url = new URL(location);
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw new Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = path.join(tmpDir, filename);
const tmpArchivePath = `${archivePath}.tmp`;
const versionDir = path.join(factorioDir, version);
const tmpFactorioDir = path.join(tmpDir, version);
// Check if version already exists
if (await fs.pathExists(versionDir)) {
return new lib.HostDownloadFactorioRequest.Response(true, version);
}
await fs.ensureDir(tmpDir);
// Download the factorio archive
const downloadRes = await fetch(url.href);
if (!downloadRes.ok) {
throw new Error(`Failed to download Factorio: ${downloadRes.statusText}`);
}
logger.info(`Downloading Factorio ${version}. This may take a while.`);
const writeStream = fs.createWriteStream(tmpArchivePath);
if (downloadRes.body) {
const readableStream = stream.Readable.fromWeb(downloadRes.body);
readableStream.pipe(writeStream);
await finished(writeStream);
} else {
throw new Error("No response body received from download");
}
await fs.rename(tmpArchivePath, archivePath);
// Extract the archive
await fs.ensureDir(tmpFactorioDir);
await this.execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
// Clean up archive and move to final location
await fs.unlink(archivePath);
await fs.rename(tmpFactorioDir, versionDir);
logger.info(`Successfully downloaded and installed Factorio ${version}`);
return new lib.HostDownloadFactorioRequest.Response(true, version);
} catch (error: any) {
logger.error(`Failed to download Factorio: ${error.message}`);
return new lib.HostDownloadFactorioRequest.Response(false, undefined, error.message);
}
}
private async getFactorioVersion(changelogPath: string): Promise<string | null> {
try {
const changelog = await fs.readFile(changelogPath, "utf-8");
for (const line of changelog.split(/[\r\n]+/)) {
const index = line.indexOf(":");
if (index !== -1) {
const name = line.slice(0, index).trim();
if (name.toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
}
return null;
} catch (error: any) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
private async execFile(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
child_process.execFile(
cmd,
args,
{ shell: true },
(err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
}
);
});
}
async fallbackInstanceSaveDetailsListRequest(
request: lib.InstanceSaveDetailsListRequest,
src: lib.Address,
+48
View File
@@ -288,3 +288,51 @@ export class HostRevokeTokensRequest {
return new this(json.hostId);
}
}
export class HostFactorioVersionsRequest {
declare ["constructor"]: typeof HostFactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.list" as const;
static Response = class Response {
constructor(
public versions: string[],
) { }
static jsonSchema = Type.Object({
"versions": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.versions);
}
};
}
export class HostDownloadFactorioRequest {
declare ["constructor"]: typeof HostDownloadFactorioRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update" as const;
static Response = class Response {
constructor(
public success: boolean,
public version?: string,
public error?: string,
) { }
static jsonSchema = Type.Object({
"success": Type.Boolean(),
"version": Type.Optional(Type.String()),
"error": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.success, json.version, json.error);
}
};
}
+2
View File
@@ -52,6 +52,8 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.PrepareControllerDisconnectRequest,
host.SyncUserListsEvent,
host.HostRevokeTokensRequest,
host.HostFactorioVersionsRequest,
host.HostDownloadFactorioRequest,
instance.InstanceDetailsGetRequest,
instance.InstanceDetailsListRequest,
@@ -15,6 +15,7 @@ import { useHost } from "../model/host";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import FactorioVersionManager from "./FactorioVersionManager";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
@@ -151,6 +152,7 @@ export default function HostViewPage() {
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
<InstanceList instances={hostInstances} size="small" hideAssignedHost />
</>}
<FactorioVersionManager hostId={hostId} system={system} connected={host.connected} />
{account.hasPermission("core.log.follow") && <>
<Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>