diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 5e113251..74a9478d 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -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 { + 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, diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 66399f19..df2ea9e6 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -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) { + 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) { + return new this(json.success, json.version, json.error); + } + }; +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 3fd30de6..6ee68355 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -52,6 +52,8 @@ export const dataClasses: (RequestClass | EventClass) host.PrepareControllerDisconnectRequest, host.SyncUserListsEvent, host.HostRevokeTokensRequest, + host.HostFactorioVersionsRequest, + host.HostDownloadFactorioRequest, instance.InstanceDetailsGetRequest, instance.InstanceDetailsListRequest, diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index bd689ee4..9eeedb97 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -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() { Instances } + {account.hasPermission("core.log.follow") && <> Console