diff --git a/src/__mocks__/service.mocks.js b/src/__mocks__/service.mocks.js index 3610f8e..5de6f87 100644 --- a/src/__mocks__/service.mocks.js +++ b/src/__mocks__/service.mocks.js @@ -25,6 +25,9 @@ export const mockFsService = { readDir: vi.fn(), makeDir: vi.fn(), deleteDir: vi.fn(), + readJsonFile: vi.fn(), + writeJsonFile: vi.fn(), + writeFile: vi.fn(), }; export const mockShellService = { diff --git a/src/handlers/processControl.js b/src/handlers/processControl.js index 51fc1c8..31b6272 100644 --- a/src/handlers/processControl.js +++ b/src/handlers/processControl.js @@ -3,6 +3,7 @@ import { spawn, exec } from "child_process"; export class ProcessControl { constructor(configService, shellService, osService, fsService) { + this.configService = configService; this.config = configService.get(); this.shell = shellService; this.os = osService; @@ -18,28 +19,17 @@ export class ProcessControl { } else { return await this.shell.run("curl -s https://ip.codex.storage"); } - } - - getLogFile = () =>{ - // function getCurrentLogFile(config) { - // const timestamp = new Date() - // .toISOString() - // .replaceAll(":", "-") - // .replaceAll(".", "-"); - // return path.join(config.logsDir, `codex_${timestamp}.log`); - // } - // todo, maybe use timestamp - - return this.fs.pathJoin([this.config.logsDir, "codex.log"]); - } + }; doThing = async () => { - if (this.config.dataDir.length < 1) throw new Error("Missing config: dataDir"); - if (this.config.logsDir.length < 1) throw new Error("Missing config: logsDir"); + if (this.config.dataDir.length < 1) + throw new Error("Missing config: dataDir"); + if (this.config.logsDir.length < 1) + throw new Error("Missing config: logsDir"); console.log("start a codex detached"); - console.log("nat: " + await this.getPublicIp()); + console.log("nat: " + (await this.getPublicIp())); console.log("logs dir: " + this.getLogFile()); console.log("data dir: " + this.config.dataDir); console.log("api port: " + this.config.ports.apiPort); @@ -64,10 +54,15 @@ export class ProcessControl { console.log("command: " + command); console.log("\n\n"); - var child = spawn(executable, args, { detached: true, stdio: ['ignore', 'ignore', 'ignore']}); + this.configService.writeCodexConfigFile(); + + var child = spawn(executable, args, { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); child.unref(); await new Promise((resolve) => setTimeout(resolve, 2000)); return; - } + }; } diff --git a/src/main.js b/src/main.js index ed4c841..87eb716 100644 --- a/src/main.js +++ b/src/main.js @@ -99,9 +99,9 @@ export async function main() { process.on("SIGTERM", handleExit); process.on("SIGQUIT", handleExit); - const configService = new ConfigService(); const uiService = new UiService(); const fsService = new FsService(); + const configService = new ConfigService(fsService); const pathSelector = new PathSelector(uiService, new MenuLoop(), fsService); const numberSelector = new NumberSelector(uiService); const shellService = new ShellService(); @@ -133,15 +133,18 @@ export async function main() { new DataDirMover(fsService, uiService), ); - const processControl = new ProcessControl(configService, shellService, osService, fsService); + const processControl = new ProcessControl( + configService, + shellService, + osService, + fsService, + ); await processControl.doThing(); return; await mainMenu.show(); return; - - try { while (true) { console.log("\n" + chalk.cyanBright(ASCII_ART)); diff --git a/src/services/configService.js b/src/services/configService.js index 3b4b3b2..05d36ee 100644 --- a/src/services/configService.js +++ b/src/services/configService.js @@ -1,8 +1,7 @@ -import fs from "fs"; -import path from "path"; -import { getAppDataDir } from "../utils/appData.js"; import { + getAppDataDir, getCodexBinPath, + getCodexConfigFilePath, getCodexDataDirDefaultPath, getCodexLogsDefaultPath, } from "../utils/appData.js"; @@ -11,6 +10,7 @@ const defaultConfig = { codexExe: "", // User-selected config options: codexInstallPath: getCodexBinPath(), + codexConfigFilePath: getCodexConfigFilePath(), dataDir: getCodexDataDirDefaultPath(), logsDir: getCodexLogsDefaultPath(), storageQuota: 8 * 1024 * 1024 * 1024, @@ -22,7 +22,8 @@ const defaultConfig = { }; export class ConfigService { - constructor() { + constructor(fsService) { + this.fs = fsService; this.loadConfig(); } @@ -33,11 +34,12 @@ export class ConfigService { loadConfig = () => { const filePath = this.getConfigFilename(); try { - if (!fs.existsSync(filePath)) { + if (!this.fs.isFile(filePath)) { this.config = defaultConfig; this.saveConfig(); + } else { + this.config = this.fs.readJsonFile(filePath); } - this.config = JSON.parse(fs.readFileSync(filePath)); } catch (error) { console.error( `Failed to load config file from '${filePath}' error: '${error}'.`, @@ -49,7 +51,7 @@ export class ConfigService { saveConfig = () => { const filePath = this.getConfigFilename(); try { - fs.writeFileSync(filePath, JSON.stringify(this.config)); + this.fs.writeJsonFile(filePath, this.config); } catch (error) { console.error( `Failed to save config file to '${filePath}' error: '${error}'.`, @@ -59,6 +61,42 @@ export class ConfigService { }; getConfigFilename = () => { - return path.join(getAppDataDir(), "config.json"); + return this.fs.pathJoin([getAppDataDir(), "config.json"]); + }; + + getLogFilePath = () => { + // function getCurrentLogFile(config) { + // const timestamp = new Date() + // .toISOString() + // .replaceAll(":", "-") + // .replaceAll(".", "-"); + // return path.join(config.logsDir, `codex_${timestamp}.log`); + // } + // todo, maybe use timestamp + + return this.fs.pathJoin([this.config.logsDir, "codex.log"]); + }; + + writeCodexConfigFile = (publicIp, bootstrapNodes) => { + const nl = "\n"; + const bootNodes = bootstrapNodes.join(","); + + this.fs.writeFile( + this.config.codexConfigFilePath, + `data-dir="${this.format(this.config.dataDir)}"${nl}` + + `log-level=DEBUG${nl}` + + `log-file="${this.format(this.getLogFilePath())}"${nl}` + + `storage-quota=${this.config.storageQuota}${nl}` + + `disc-port=${this.config.ports.discPort}${nl}` + + `listen-addrs=/ip4/0.0.0.0/tcp/${this.config.ports.listenPort}${nl}` + + `api-port=${this.config.ports.apiPort}${nl}` + + `nat=extip:${publicIp}${nl}` + + `api-cors-origin="*"${nl}` + + `bootstrap-node=[${bootNodes}]`, + ); + }; + + format = (str) => { + return str.replaceAll("\\", "/"); }; } diff --git a/src/services/configService.test.js b/src/services/configService.test.js new file mode 100644 index 0000000..3fc67ee --- /dev/null +++ b/src/services/configService.test.js @@ -0,0 +1,129 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { ConfigService } from "./configService.js"; +import { mockFsService } from "../__mocks__/service.mocks.js"; +import { + getAppDataDir, + getCodexBinPath, + getCodexConfigFilePath, + getCodexDataDirDefaultPath, + getCodexLogsDefaultPath, +} from "../utils/appData.js"; + +describe("ConfigService", () => { + const configPath = "/path/to/config.json"; + const expectedDefaultConfig = { + codexExe: "", + codexInstallPath: getCodexBinPath(), + codexConfigFilePath: getCodexConfigFilePath(), + dataDir: getCodexDataDirDefaultPath(), + logsDir: getCodexLogsDefaultPath(), + storageQuota: 8 * 1024 * 1024 * 1024, + ports: { + discPort: 8090, + listenPort: 8070, + apiPort: 8080, + }, + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mockFsService.pathJoin.mockReturnValue(configPath); + }); + + describe("constructor", () => { + it("formats the config file path", () => { + new ConfigService(mockFsService); + + expect(mockFsService.pathJoin).toHaveBeenCalledWith([ + getAppDataDir(), + "config.json", + ]); + }); + + it("saves the default config when the config.json file does not exist", () => { + mockFsService.isFile.mockReturnValue(false); + + const service = new ConfigService(mockFsService); + + expect(mockFsService.isFile).toHaveBeenCalledWith(configPath); + expect(mockFsService.readJsonFile).not.toHaveBeenCalled(); + expect(mockFsService.writeJsonFile).toHaveBeenCalledWith( + configPath, + service.config, + ); + expect(service.config).toEqual(expectedDefaultConfig); + }); + + it("loads the config.json file when it does exist", () => { + mockFsService.isFile.mockReturnValue(true); + const savedConfig = { + isTestConfig: "Yes, very", + }; + mockFsService.readJsonFile.mockReturnValue(savedConfig); + + const service = new ConfigService(mockFsService); + + expect(mockFsService.isFile).toHaveBeenCalledWith(configPath); + expect(mockFsService.readJsonFile).toHaveBeenCalledWith(configPath); + expect(mockFsService.writeJsonFile).not.toHaveBeenCalled(); + expect(service.config).toEqual(savedConfig); + }); + }); + + describe("getLogFilePath", () => { + it("joins the logsDir with the log filename", () => { + const service = new ConfigService(mockFsService); + + const result = "path/to/codex.log"; + mockFsService.pathJoin.mockReturnValue(result); + + expect(service.getLogFilePath()).toBe(result); + expect(mockFsService.pathJoin).toHaveBeenCalledWith([ + expectedDefaultConfig.logsDir, + "codex.log", + ]); + }); + }); + + describe("writecodexConfigFile", () => { + const logsPath = "C:\\path\\codex.log"; + var configService; + + beforeEach(() => { + // use the default config: + mockFsService.isFile.mockReturnValue(false); + + configService = new ConfigService(mockFsService); + configService.getLogFilePath = vi.fn(); + configService.getLogFilePath.mockReturnValue(logsPath); + }); + + function formatPath(str) { + return str.replaceAll("\\", "/"); + } + + it("writes the config file values to the config TOML file", () => { + const publicIp = "1.2.3.4"; + const bootstrapNodes = ["boot111", "boot222", "boot333"]; + + configService.writeCodexConfigFile(publicIp, bootstrapNodes); + + const newLine = "\n"; + + expect(mockFsService.writeFile).toHaveBeenCalledWith( + expectedDefaultConfig.codexConfigFilePath, + `data-dir=\"${formatPath(expectedDefaultConfig.dataDir)}"${newLine}` + + `log-level=DEBUG${newLine}` + + `log-file="${formatPath(logsPath)}"${newLine}` + + `storage-quota=${expectedDefaultConfig.storageQuota}${newLine}` + + `disc-port=${expectedDefaultConfig.ports.discPort}${newLine}` + + `listen-addrs=/ip4/0.0.0.0/tcp/${expectedDefaultConfig.ports.listenPort}${newLine}` + + `api-port=${expectedDefaultConfig.ports.apiPort}${newLine}` + + `nat=extip:${publicIp}${newLine}` + + `api-cors-origin="*"${newLine}` + + `bootstrap-node=[${bootstrapNodes.join(",")}]`, + ); + }); + }); +}); diff --git a/src/services/fsService.js b/src/services/fsService.js index fee2b05..9b44a9a 100644 --- a/src/services/fsService.js +++ b/src/services/fsService.js @@ -54,4 +54,17 @@ export class FsService { deleteDir = (dir) => { fs.rmSync(dir, { recursive: true, force: true }); }; + + readJsonFile = (filePath) => { + return JSON.parse(fs.readFileSync(filePath)); + }; + + writeJsonFile = (filePath, jsonObject) => { + fs.writeFileSync(filePath, JSON.stringify(jsonObject)); + }; + + writeFile = (filePath, content) => { + console.log("filepath: " + filePath); + fs.writeFileSync(filePath, content); + }; } diff --git a/src/utils/appData.js b/src/utils/appData.js index 77dc950..d375cad 100644 --- a/src/utils/appData.js +++ b/src/utils/appData.js @@ -13,6 +13,10 @@ export function getCodexBinPath() { return ensureExists(path.join(appData("codex"), "bin")); } +export function getCodexConfigFilePath() { + return path.join(appData("codex"), "bin", "config.toml"); +} + export function getCodexDataDirDefaultPath() { // This path does not exist on first startup. That's good: Codex will // create it with the required access permissions.