diff --git a/src/__mocks__/service.mocks.js b/src/__mocks__/service.mocks.js index 06c69ae..5b735f2 100644 --- a/src/__mocks__/service.mocks.js +++ b/src/__mocks__/service.mocks.js @@ -18,6 +18,18 @@ export const mockFsService = { getAvailableRoots: vi.fn(), pathJoin: vi.fn(), isDir: vi.fn(), + isFile: vi.fn(), readDir: vi.fn(), makeDir: vi.fn(), }; + +export const mockShellService = { + run: vi.fn(), +}; + +export const mockOsService = { + isWindows: vi.fn(), + isDarwin: vi.fn(), + isLinux: vi.fn(), + getWorkingDir: vi.fn(), +}; diff --git a/src/handlers/installationHandlers.js b/src/handlers/installationHandlers.js index 50cc860..d026c5c 100644 --- a/src/handlers/installationHandlers.js +++ b/src/handlers/installationHandlers.js @@ -135,14 +135,6 @@ async function performInstall(config) { try { if (platform === "win32") { try { - try { - await runCommand("curl --version"); - } catch (error) { - throw new Error( - "curl is not available. Please install curl or update your Windows version.", - ); - } - await runCommand( "curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd", ); diff --git a/src/handlers/installer.js b/src/handlers/installer.js index 57ba9bd..9fb80a9 100644 --- a/src/handlers/installer.js +++ b/src/handlers/installer.js @@ -1,9 +1,130 @@ export class Installer { - constructor(configService) { + constructor(configService, shellService, osService, fsService) { + this.config = configService.get(); this.configService = configService; + this.shell = shellService; + this.os = osService; + this.fs = fsService; } isCodexInstalled = async () => { - return false; - } -} \ No newline at end of file + try { + await this.getCodexVersion(); + return true; + } catch (error) { + return false; + } + }; + + getCodexVersion = async () => { + if (this.config.codexExe.length < 1) + throw new Error("Codex not installed."); + const version = await this.shell.run(`"${this.config.codexExe}" --version`); + if (version.length < 1) throw new Error("Version info not found."); + return version; + }; + + installCodex = async (processCallbacks) => { + if (!(await this.arePrerequisitesCorrect(processCallbacks))) return; + + processCallbacks.installStarts(); + if (this.os.isWindows()) { + await this.installCodexWindows(processCallbacks); + } else { + await this.installCodexUnix(processCallbacks); + } + + if (!(await this.isCodexInstalled())) + throw new Error("Codex installation failed."); + processCallbacks.installSuccessful(); + }; + + arePrerequisitesCorrect = async (processCallbacks) => { + if (await this.isCodexInstalled()) { + processCallbacks.warn("Codex is already installed."); + return false; + } + if (this.config.codexInstallPath.length < 1) { + processCallbacks.warn("Install path not set."); + return false; + } + if (!(await this.isCurlAvailable())) { + processCallbacks.warn("Curl is not available."); + return false; + } + return true; + }; + + isCurlAvailable = async () => { + const curlVersion = await this.shell.run("curl --version"); + return curlVersion.length > 0; + }; + + installCodexWindows = async (processCallbacks) => { + await this.shell.run( + "curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd", + ); + processCallbacks.downloadSuccessful(); + await this.shell.run( + `set "INSTALL_DIR=${this.config.codexInstallPath}" && ` + + `"${this.os.getWorkingDir()}\\install.cmd"`, + ); + await this.saveCodexInstallPath("codex.exe"); + await this.shell.run("del /f install.cmd"); + }; + + installCodexUnix = async (processCallbacks) => { + await this.ensureUnixDependencies(); + await this.shell.run( + "curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh", + ); + processCallbacks.downloadSuccessful(); + + if (this.os.isDarwin()) { + await this.runInstallerDarwin(); + } else { + await this.runInstallerLinux(); + } + + await this.saveCodexInstallPath("codex"); + await this.shell.run("rm -f install.sh"); + }; + + runInstallerDarwin = async () => { + const timeoutCommand = `perl -e ' + eval { + local $SIG{ALRM} = sub { die "timeout\\n" }; + alarm(120); + system("INSTALL_DIR=\\"${this.config.codexInstallPath}\\" bash install.sh"); + alarm(0); + }; + die if $@; +'`; + await this.shell.run(timeoutCommand); + }; + + runInstallerLinux = async () => { + await this.shell.run( + `INSTALL_DIR="${this.config.codexInstallPath}" timeout 120 bash install.sh`, + ); + }; + + ensureUnixDependencies = async (processCallbacks) => { + const libgompCheck = await this.shell.run("ldconfig -p | grep libgomp"); + if (libgompCheck.length < 1) { + processCallbacks.warn("libgomp not found."); + return false; + } + return true; + }; + + saveCodexInstallPath = async (codexExe) => { + this.config.codexExe = this.fs.pathJoin([ + this.config.codexInstallPath, + codexExe, + ]); + if (!this.fs.isFile(this.config.codexExe)) + throw new Error("Codex executable not found."); + await this.configService.saveConfig(); + }; +} diff --git a/src/handlers/installer.test.js b/src/handlers/installer.test.js new file mode 100644 index 0000000..4b011d9 --- /dev/null +++ b/src/handlers/installer.test.js @@ -0,0 +1,372 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { + mockShellService, + mockOsService, + mockFsService, +} from "../__mocks__/service.mocks.js"; +import { mockConfigService } from "../__mocks__/service.mocks.js"; +import { Installer } from "./installer.js"; + +describe("Installer", () => { + const config = { + codexInstallPath: "/install-codex", + }; + const workingDir = "/working-dir"; + const processCallbacks = { + installStarts: vi.fn(), + downloadSuccessful: vi.fn(), + installSuccessful: vi.fn(), + warn: vi.fn(), + }; + let installer; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfigService.get.mockReturnValue(config); + mockOsService.getWorkingDir.mockReturnValue(workingDir); + + installer = new Installer( + mockConfigService, + mockShellService, + mockOsService, + mockFsService, + ); + }); + + describe("getCodexVersion", () => { + it("throws when codex exe is not set", async () => { + config.codexExe = ""; + await expect(installer.getCodexVersion()).rejects.toThrow( + "Codex not installed.", + ); + }); + + it("throws when version info is not found", async () => { + config.codexExe = "codex.exe"; + mockShellService.run.mockResolvedValueOnce(""); + await expect(installer.getCodexVersion()).rejects.toThrow( + "Version info not found.", + ); + }); + + it("returns version info", async () => { + const versionInfo = "versionInfo"; + config.codexExe = "codex.exe"; + mockShellService.run.mockResolvedValueOnce(versionInfo); + const version = await installer.getCodexVersion(); + expect(version).toBe(versionInfo); + }); + }); + + describe("isCodexInstalled", () => { + it("return true when getCodexVersion succeeds", async () => { + installer.getCodexVersion = vi.fn(); + expect(await installer.isCodexInstalled()).toBe(true); + }); + + it("returns false when getCodexVersion fails", async () => { + installer.getCodexVersion = vi.fn(() => { + throw new Error("Codex not installed."); + }); + expect(await installer.isCodexInstalled()).toBe(false); + }); + }); + + describe("installCodex", () => { + beforeEach(() => { + installer.arePrerequisitesCorrect = vi.fn(); + installer.installCodexWindows = vi.fn(); + installer.installCodexUnix = vi.fn(); + installer.isCodexInstalled = vi.fn(); + }); + + it("returns early when prerequisites are not correct", async () => { + installer.arePrerequisitesCorrect.mockResolvedValue(false); + await installer.installCodex(processCallbacks); + expect(processCallbacks.installStarts).not.toHaveBeenCalled(); + expect(processCallbacks.installSuccessful).not.toHaveBeenCalled(); + expect(processCallbacks.downloadSuccessful).not.toHaveBeenCalled(); + expect(installer.isCodexInstalled).not.toHaveBeenCalled(); + expect(installer.installCodexWindows).not.toHaveBeenCalled(); + expect(installer.installCodexUnix).not.toHaveBeenCalled(); + }); + + describe("prerequisites OK", () => { + beforeEach(() => { + installer.arePrerequisitesCorrect.mockResolvedValue(true); + installer.isCodexInstalled.mockResolvedValue(true); + }); + + it("calls installStarts when prerequisites are correct", async () => { + await installer.installCodex(processCallbacks); + expect(processCallbacks.installStarts).toHaveBeenCalled(); + }); + + it("calls installCodexWindows when OS is Windows", async () => { + mockOsService.isWindows.mockReturnValue(true); + await installer.installCodex(processCallbacks); + expect(installer.installCodexWindows).toHaveBeenCalledWith( + processCallbacks, + ); + }); + + it("calls installCodexUnix when OS is not Windows", async () => { + mockOsService.isWindows.mockReturnValue(false); + await installer.installCodex(processCallbacks); + expect(installer.installCodexUnix).toHaveBeenCalledWith( + processCallbacks, + ); + }); + + it("throws when codex is not installed after installation", async () => { + installer.isCodexInstalled.mockResolvedValue(false); + await expect(installer.installCodex(processCallbacks)).rejects.toThrow( + "Codex installation failed.", + ); + }); + + it("calls installSuccessful when installation is successful", async () => { + await installer.installCodex(processCallbacks); + expect(processCallbacks.installSuccessful).toHaveBeenCalled(); + }); + }); + }); + + describe("arePrerequisitesCorrect", () => { + beforeEach(() => { + installer.isCodexInstalled = vi.fn(); + installer.isCurlAvailable = vi.fn(); + config.codexInstallPath = "/install-codex"; + }); + + it("returns false when codex is already installed", async () => { + installer.isCodexInstalled.mockResolvedValue(true); + expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe( + false, + ); + expect(processCallbacks.warn).toHaveBeenCalledWith( + "Codex is already installed.", + ); + }); + + it("returns false when install path is not set", async () => { + config.codexInstallPath = ""; + expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe( + false, + ); + expect(processCallbacks.warn).toHaveBeenCalledWith( + "Install path not set.", + ); + }); + + it("returns false when curl is not available", async () => { + installer.isCodexInstalled.mockResolvedValue(false); + installer.isCurlAvailable.mockResolvedValue(false); + expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe( + false, + ); + expect(processCallbacks.warn).toHaveBeenCalledWith( + "Curl is not available.", + ); + }); + + it("returns true when all prerequisites are correct", async () => { + installer.isCodexInstalled.mockResolvedValue(false); + installer.isCurlAvailable.mockResolvedValue(true); + const result = await installer.arePrerequisitesCorrect(processCallbacks); + expect(result).toBe(true); + }); + }); + + describe("isCurlAvailable", () => { + it("returns true when curl version is found", async () => { + mockShellService.run.mockResolvedValueOnce("curl version"); + const result = await installer.isCurlAvailable(); + expect(mockShellService.run).toHaveBeenCalledWith("curl --version"); + expect(result).toBe(true); + }); + + it("returns false when curl version is not found", async () => { + mockShellService.run.mockResolvedValueOnce(""); + const result = await installer.isCurlAvailable(); + expect(mockShellService.run).toHaveBeenCalledWith("curl --version"); + expect(result).toBe(false); + }); + }); + + describe("install functions", () => { + beforeEach(() => { + installer.saveCodexInstallPath = vi.fn(); + }); + + describe("installCodexWindows", () => { + it("runs the curl command to download the installer", async () => { + await installer.installCodexWindows(processCallbacks); + expect(mockShellService.run).toHaveBeenCalledWith( + "curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd", + ); + }); + + it("calls downloadSuccessful", async () => { + await installer.installCodexWindows(processCallbacks); + expect(processCallbacks.downloadSuccessful).toHaveBeenCalled(); + }); + + it("runs installer script", async () => { + await installer.installCodexWindows(processCallbacks); + expect(mockShellService.run).toHaveBeenCalledWith( + `set "INSTALL_DIR=${config.codexInstallPath}" && "${workingDir}\\install.cmd"`, + ); + }); + + it("saves the codex install path", async () => { + await installer.installCodexWindows(processCallbacks); + expect(installer.saveCodexInstallPath).toHaveBeenCalledWith( + "codex.exe", + ); + }); + + it("deletes the installer script", async () => { + await installer.installCodexWindows(processCallbacks); + expect(mockShellService.run).toHaveBeenCalledWith("del /f install.cmd"); + }); + }); + + describe("installCodexUnix", () => { + beforeEach(() => { + installer.ensureUnixDependencies = vi.fn(); + installer.runInstallerDarwin = vi.fn(); + installer.runInstallerLinux = vi.fn(); + }); + + it("ensures Unix dependencies", async () => { + await installer.installCodexUnix(processCallbacks); + expect(installer.ensureUnixDependencies).toHaveBeenCalled(); + }); + + it("runs the curl command to download the installer", async () => { + await installer.installCodexUnix(processCallbacks); + expect(mockShellService.run).toHaveBeenCalledWith( + "curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh", + ); + }); + + it("calls downloadSuccessful", async () => { + await installer.installCodexUnix(processCallbacks); + expect(processCallbacks.downloadSuccessful).toHaveBeenCalled(); + }); + + it("runs installer for darwin ", async () => { + mockOsService.isDarwin.mockReturnValue(true); + await installer.installCodexUnix(processCallbacks); + expect(installer.runInstallerDarwin).toHaveBeenCalled(); + }); + + it("runs installer for linux", async () => { + mockOsService.isDarwin.mockReturnValue(false); + await installer.installCodexUnix(processCallbacks); + expect(installer.runInstallerLinux).toHaveBeenCalled(); + }); + + it("saves the codex install path", async () => { + await installer.installCodexUnix(processCallbacks); + expect(installer.saveCodexInstallPath).toHaveBeenCalledWith("codex"); + }); + + it("deletes the installer script", async () => { + await installer.installCodexUnix(processCallbacks); + expect(mockShellService.run).toHaveBeenCalledWith("rm -f install.sh"); + }); + }); + + describe("runInstallerDarwin", () => { + it("runs the installer script for darwin with custom timeout command", async () => { + const timeoutCommand = `perl -e ' + eval { + local $SIG{ALRM} = sub { die "timeout\\n" }; + alarm(120); + system("INSTALL_DIR=\\"${config.codexInstallPath}\\" bash install.sh"); + alarm(0); + }; + die if $@; +'`; + await installer.runInstallerDarwin(); + expect(mockShellService.run).toHaveBeenCalledWith(timeoutCommand); + }); + }); + + describe("runInstallerLinux", () => { + it("runs the installer script using unix timeout command", async () => { + await installer.runInstallerLinux(); + expect(mockShellService.run).toHaveBeenCalledWith( + `INSTALL_DIR="${config.codexInstallPath}" timeout 120 bash install.sh`, + ); + }); + }); + }); + + describe("ensureUnixDependencies", () => { + it("returns true when libgomp is installed", async () => { + mockShellService.run.mockResolvedValueOnce("yes"); + expect(await installer.ensureUnixDependencies(processCallbacks)).toBe( + true, + ); + expect(mockShellService.run).toHaveBeenCalledWith( + "ldconfig -p | grep libgomp", + ); + }); + + it("returns false when libgomp is not found", async () => { + mockShellService.run.mockResolvedValue(""); + expect(await installer.ensureUnixDependencies(processCallbacks)).toBe( + false, + ); + expect(mockShellService.run).toHaveBeenCalledWith( + "ldconfig -p | grep libgomp", + ); + }); + + it("it calls warn in processCallbacks when libgomp is not found", async () => { + mockShellService.run.mockResolvedValue(""); + await installer.ensureUnixDependencies(processCallbacks); + expect(processCallbacks.warn).toHaveBeenCalledWith("libgomp not found."); + }); + }); + + describe("saveCodexInstallPath", () => { + const codexExe = "_codex_.exe"; + const pathJointResult = "/path-to-codex/_codex_.exe"; + + beforeEach(() => { + mockFsService.pathJoin.mockReturnValue(pathJointResult); + }); + + it("combines the install path with the exe", async () => { + mockFsService.isFile.mockReturnValue(true); + await installer.saveCodexInstallPath(codexExe); + expect(mockFsService.pathJoin).toHaveBeenCalledWith([ + config.codexInstallPath, + codexExe, + ]); + }); + + it("sets the codex exe path", async () => { + mockFsService.isFile.mockReturnValue(true); + await installer.saveCodexInstallPath(codexExe); + expect(config.codexExe).toBe(pathJointResult); + }); + + it("throws when file does not exist", async () => { + mockFsService.isFile.mockReturnValue(false); + await expect(installer.saveCodexInstallPath(codexExe)).rejects.toThrow( + "Codex executable not found.", + ); + }); + + it("saves the config", async () => { + mockFsService.isFile.mockReturnValue(true); + await installer.saveCodexInstallPath(codexExe); + expect(mockConfigService.saveConfig).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main.js b/src/main.js index 4cd4c53..060fa86 100644 --- a/src/main.js +++ b/src/main.js @@ -112,7 +112,7 @@ export async function main() { new MenuLoop(), installMenu, configMenu, - new DataDirMover(fsService, uiService) + new DataDirMover(fsService, uiService), ); await mainMenu.show(); diff --git a/src/services/fsService.js b/src/services/fsService.js index 645a099..8590652 100644 --- a/src/services/fsService.js +++ b/src/services/fsService.js @@ -31,6 +31,14 @@ export class FsService { } }; + isFile = (path) => { + try { + return fs.lstatSync(path).isFile(); + } catch { + return false; + } + }; + readDir = (dir) => { return fs.readdirSync(dir); }; diff --git a/src/services/osService.js b/src/services/osService.js new file mode 100644 index 0000000..3ec8f05 --- /dev/null +++ b/src/services/osService.js @@ -0,0 +1,23 @@ +import os from "os"; + +export class OsService { + constructor() { + this.platform = os.platform(); + } + + isWindows = () => { + return this.platform === "win32"; + }; + + isDarwin = () => { + return this.platform === "darwin"; + }; + + isLinux = () => { + return this.platform === "linux"; + }; + + getWorkingDir = () => { + return process.cwd(); + }; +} diff --git a/src/services/shellService.js b/src/services/shellService.js new file mode 100644 index 0000000..df9f63e --- /dev/null +++ b/src/services/shellService.js @@ -0,0 +1,18 @@ +import { exec } from "child_process"; +import { promisify } from "util"; + +export class ShellService { + constructor() { + this.execAsync = promisify(exec); + } + + async run(command) { + try { + const { stdout, stderr } = await this.execAsync(command); + return stdout; + } catch (error) { + console.error("Error:", error.message); + throw error; + } + } +} diff --git a/src/ui/installMenu.js b/src/ui/installMenu.js index b42eb82..4c4b50d 100644 --- a/src/ui/installMenu.js +++ b/src/ui/installMenu.js @@ -9,7 +9,7 @@ export class InstallMenu { show = async () => { await this.ui.askMultipleChoice("Configure your Codex installation", [ { - label: "Install path: " + this.config.codexPath, + label: "Install path: " + this.config.codexInstallPath, action: this.selectInstallPath, }, { @@ -28,8 +28,8 @@ export class InstallMenu { }; selectInstallPath = async () => { - this.config.codexPath = await this.pathSelector.show( - this.config.codexPath, + this.config.codexInstallPath = await this.pathSelector.show( + this.config.codexInstallPath, false, ); this.configService.saveConfig(); diff --git a/src/ui/installMenu.test.js b/src/ui/installMenu.test.js index 1a0c7c9..2e15e89 100644 --- a/src/ui/installMenu.test.js +++ b/src/ui/installMenu.test.js @@ -6,7 +6,7 @@ import { mockPathSelector } from "../__mocks__/utils.mocks.js"; describe("InstallMenu", () => { const config = { - codexPath: "/codex", + codexInstallPath: "/codex", }; let installMenu; @@ -27,7 +27,7 @@ describe("InstallMenu", () => { "Configure your Codex installation", [ { - label: "Install path: " + config.codexPath, + label: "Install path: " + config.codexInstallPath, action: installMenu.selectInstallPath, }, { @@ -47,14 +47,14 @@ describe("InstallMenu", () => { }); it("allows selecting the install path", async () => { - const originalPath = config.codexPath; + const originalPath = config.codexInstallPath; const newPath = "/new/path"; mockPathSelector.show.mockResolvedValue(newPath); await installMenu.selectInstallPath(); expect(mockPathSelector.show).toHaveBeenCalledWith(originalPath, false); - expect(config.codexPath).toBe(newPath); + expect(config.codexInstallPath).toBe(newPath); expect(mockConfigService.saveConfig).toHaveBeenCalled(); }); diff --git a/src/utils/command.js b/src/utils/command.js deleted file mode 100644 index 9ef5c91..0000000 --- a/src/utils/command.js +++ /dev/null @@ -1,14 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; - -export const execAsync = promisify(exec); - -export async function runCommand(command) { - try { - const { stdout, stderr } = await execAsync(command); - return stdout; - } catch (error) { - console.error("Error:", error.message); - throw error; - } -}