diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 5ec98b0c..3aa41acf 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -346,8 +346,7 @@ async function initialize(): Promise { const controllerConfigPath = args.config; logger.info(`Loading config from ${controllerConfigPath}`); try { - let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" })); - controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller", controllerConfigPath); + controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath); } catch (err: any) { if (err.code === "ENOENT") { @@ -366,9 +365,7 @@ async function initialize(): Promise { let asyncRandomBytes = util.promisify(crypto.randomBytes); let bytes = await asyncRandomBytes(256); controllerConfig.set("controller.auth_secret", bytes.toString("base64")); - await lib.safeOutputFile( - controllerConfigPath, JSON.stringify(controllerConfig, null, "\t") - ); + await controllerConfig.save(); } if (command === "config") { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 0c80fa80..6b60ac68 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -581,12 +581,8 @@ export default class Controller { }); private async _saveDataInternal() { - if (this.config.dirty) { - this.config.dirty = false; - await lib.safeOutputFile(this.config.filepath!, JSON.stringify(this.config, null, "\t")); - } - await Promise.all([ + this.config.save(), this.systems.save(), this.hosts.save(), this.instances.save(), diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 13616d29..ef363f73 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -43,12 +43,13 @@ async function discoverInstances(instancesDir: string) { let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { - let instanceConfig = new lib.InstanceConfig("host"); - let configPath = path.join(instancesDir, entry.name, "instance.json"); + let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning); + const instancePath = path.join(instancesDir, entry.name); + const configPath = path.join(instancePath, "instance.json"); try { - const jsonConfig = JSON.parse(await fs.readFile(configPath, "utf8")); - instanceConfig = lib.InstanceConfig.fromJSON(jsonConfig, "host", configPath); + instanceConfig = await lib.InstanceConfig.fromFile("host", configPath); + instanceConfig.update(Host.instanceConfigWarning, false); // File might be missing the warning } catch (err: any) { if (err.code === "ENOENT") { @@ -64,7 +65,6 @@ async function discoverInstances(instancesDir: string) { continue; } - let instancePath = path.join(instancesDir, entry.name); logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`); instanceInfos.set(instanceConfig.get("instance.id"), { path: instancePath, @@ -278,6 +278,10 @@ export default class Host extends lib.Link { _disconnecting = false; _shuttingDown = false; + static instanceConfigWarning = { + "_warning": "Changes to this file will be overwritten by the controller's copy.", + }; + static async bootstrap(hostConfig: lib.HostConfig) { const modsDirectory = hostConfig.get("host.mods_directory"); await fs.ensureDir(modsDirectory); @@ -652,7 +656,7 @@ export default class Host extends lib.Link { instanceInfo.config.update(config, true, "controller"); } else { - let instanceConfig = new lib.InstanceConfig("host"); + let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning); instanceConfig.update(config, false, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -691,15 +695,7 @@ export default class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); // save a copy of the instance config - await this.saveInstanceConfig(instanceInfo); - } - - async saveInstanceConfig(instanceInfo: { path: string, config: lib.InstanceConfig }) { - let warnedOutput = { - _warning: "Changes to this file will be overwritten by the controller's copy.", - ...instanceInfo.config.toJSON(), - }; - await lib.safeOutputFile(instanceInfo.config.filepath!, JSON.stringify(warnedOutput, null, "\t")); + await instanceInfo.config.save(); } async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) { @@ -1152,11 +1148,11 @@ export default class Host extends lib.Link { } logger.info("Saving config"); - await lib.safeOutputFile(this.config.filepath!, JSON.stringify(this.config, null, "\t")); - for (const instanceInfo of this.instanceInfos.values()) { - // Save in case host_assigned_game_port changed in the config. - await this.saveInstanceConfig(instanceInfo); - } + await this.config.save(); + // Save host side in case host_assigned_game_port changed in the config. + await Promise.all( + [...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save()) + ); try { // Clear silly interval in pidfile library. diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index dd6c663d..bebba07c 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -1,11 +1,13 @@ // Configuration classes import { Type, Static } from "@sinclair/typebox"; -import TypedEventEmitter from "../TypedEventEmitter"; +import * as fs from "fs-extra"; +import TypedEventEmitter from "../TypedEventEmitter"; import isDeepStrictEqual from "../is_deep_strict_equal"; import { basicType } from "../helpers"; import * as libSchema from "../schema"; import { StringEnum } from "../data/composites"; +import { safeOutputFile } from "../file_ops"; const ConfigLocation = StringEnum(["controller", "host", "control"]); export type ConfigLocation = Static; @@ -206,7 +208,7 @@ export class Config< private _unknownFields: Record = {}; /** Set to true when a field in the config is changed. */ - dirty = false; + private dirty = false; /** Set to true when a 'restart required' field in the config is changed. */ restartRequired = false; @@ -301,8 +303,31 @@ export class Config< } } - static jsonSchema = ConfigSchema; + /** + * Create config from file + * + * @param location - Location used for access control. + * @param filepath - Filepath for the config to save to. + * @returns Instance of this config + */ + static async fromFile(location: ConfigLocation, filepath: string) { + const fields = await fs.readJson(filepath, { encoding: "utf8" }); + return this.fromJSON(fields, location, filepath); + } + /** Saves this config to file if there are unsaved changes */ + async save() { + if (!this.dirty) { + return; + } + if (!this.filepath) { + throw new Error("Cannot save config which has no filepath"); + } + await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t")); + this.dirty = false; + } + + static jsonSchema = ConfigSchema; static validate = libSchema.compile(this.jsonSchema as any); /** diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index f6497df2..5f670e38 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -4,6 +4,7 @@ import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; import { Static } from "@sinclair/typebox"; type configFromJSON = (...args: Parameters) => T; +type configFromFile = (...args: Parameters) => Promise; export interface ControllerConfigFields { "controller.version": string; @@ -39,6 +40,7 @@ export interface ControllerConfigFields { */ export class ControllerConfig extends classes.Config { declare static fromJSON: configFromJSON; + declare static fromFile: configFromFile; static migrations(config: Static) { if (config.hasOwnProperty("controller.external_address")) { config["controller.public_url"] = config["controller.external_address"]; @@ -246,6 +248,7 @@ export interface HostConfigFields { */ export class HostConfig extends classes.Config { declare static fromJSON: configFromJSON; + declare static fromFile: configFromFile; static fieldDefinitions: classes.ConfigDefs = { "host.version": { type: "string", @@ -378,6 +381,7 @@ export interface InstanceConfigFields { */ export class InstanceConfig extends classes.Config { declare static fromJSON: configFromJSON; + declare static fromFile: configFromFile; static migrations(config: Static) { function boolToEnableDisable(name: string) { if (config.hasOwnProperty(name) && typeof config[name] === "boolean") { @@ -571,6 +575,7 @@ export interface ControlConfigFields { */ export class ControlConfig extends classes.Config { declare static fromJSON: configFromJSON; + declare static fromFile: configFromFile; static fieldDefinitions: classes.ConfigDefs = { "control.controller_url": { description: "URL to connect to the controller at", diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index 6197bf52..71b8b509 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -164,7 +164,7 @@ export async function handleConfigCommand( try { instance.set(args.field as string, args.value); - await libFileOps.safeOutputFile(instance.filepath!, JSON.stringify(instance, null, "\t")); + await instance.save(); } catch (err) { if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) { logger.error(err.message); diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 0eeb49e4..0825dd20 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -20,6 +20,7 @@ "./src/schema.ts", "./src/subscriptions.ts", "./src/zip_ops.ts", + "./src/file_ops.ts", "./src/ExponentialBackoff.ts", "./src/RateLimiter.ts", "./src/TypedEventEmitter.ts", diff --git a/test/host.js b/test/host.js index 559da84f..54fd8ffe 100644 --- a/test/host.js +++ b/test/host.js @@ -15,6 +15,7 @@ describe("Host testing", function() { const configPath = path.join(instancePath, "test", "instance.json"); const referenceConfig = new lib.InstanceConfig("host", { + ...Host.instanceConfigWarning, "instance.id": 1, "instance.name": "test", }, configPath);