This commit is contained in:
thatben 2025-03-07 13:14:32 +01:00
parent 2297d01966
commit 769fb06ad7
No known key found for this signature in database
GPG Key ID: 62C543548433D43E
10 changed files with 470 additions and 295 deletions

View File

@ -24,6 +24,7 @@ import { showConfigMenu } from "./configmenu.js";
import { openCodexApp } from "./services/codexapp.js";
import { MainMenu } from "./ui/mainmenu.js";
import { InstallMenu } from "./ui/installmenu.js";
import { UiService } from "./services/uiservice.js";
async function showNavigationMenu() {
@ -89,14 +90,15 @@ export async function main() {
process.on("SIGTERM", handleExit);
process.on("SIGQUIT", handleExit);
const config = loadConfig();
const uiService = new UiService();
const mainMenu = new MainMenu(uiService);
const installMenu = new InstallMenu(uiService, config);
const mainMenu = new MainMenu(uiService, installMenu);
await mainMenu.show();
return;
try {
const config = loadConfig();
while (true) {
console.log("\n" + chalk.cyanBright(ASCII_ART));
const { choice } = await inquirer

View File

@ -2,6 +2,7 @@ import fs from "fs";
import path from "path";
import { getAppDataDir } from "../utils/appdata.js";
import {
getCodexBinPath,
getCodexDataDirDefaultPath,
getCodexLogsDefaultPath,
} from "../utils/appdata.js";
@ -9,6 +10,7 @@ import {
const defaultConfig = {
codexExe: "",
// User-selected config options:
codexPath: getCodexBinPath(),
dataDir: getCodexDataDirDefaultPath(),
logsDir: getCodexLogsDefaultPath(),
storageQuota: 8 * 1024 * 1024 * 1024,

41
src/services/fsservice.js Normal file
View File

@ -0,0 +1,41 @@
import path from "path";
import fs from "fs";
import { filesystemSync } from "fs-filesystem";
export class FsService {
getAvailableRoots = () => {
const devices = filesystemSync();
var mountPoints = [];
Object.keys(devices).forEach(function (key) {
var val = devices[key];
val.volumes.forEach(function (volume) {
mountPoints.push(volume.mountPoint);
});
});
if (mountPoints.length < 1) {
throw new Error("Failed to detect file system devices.");
}
return mountPoints;
};
pathJoin = (parts) => {
return path.join(...parts);
};
isDir = (dir) => {
try {
return fs.lstatSync(dir).isDirectory();
} catch {
return false;
}
};
readDir = (dir) => {
return fs.readdirSync(dir);
};
makeDir = (dir) => {
fs.mkdirSync(dir);
};
}

View File

@ -55,7 +55,7 @@ export class UiService {
askMultipleChoice = async (message, choices) => {
var counter = 1;
var promptChoices = [];
choices.forEach(function(choice) {
choices.forEach(function (choice) {
promptChoices.push(`${counter}. ${choice.label}`);
counter++;
});
@ -67,8 +67,8 @@ export class UiService {
message: message,
choices: promptChoices,
pageSize: counter - 1,
loop: true
}
loop: true,
},
]);
const selectStr = choice.split(".")[0];
@ -76,4 +76,15 @@ export class UiService {
await choices[selectIndex].action();
};
askPrompt = async (prompt) => {
const response = await inquirer.prompt([
{
type: "input",
name: "valueStr",
message: prompt,
},
]);
return response.valueStr;
};
}

38
src/ui/installmenu.js Normal file
View File

@ -0,0 +1,38 @@
export class InstallMenu {
constructor(uiService, config) {
this.ui = uiService;
this.config = config;
}
show = async () => {
await this.ui.askMultipleChoice("Configure your Codex installation", [
{
label: "Install path: " + this.config.codexPath,
action: async function () {
console.log("run path selector");
},
},
{
label: "Storage provider module: Disabled (todo)",
action: this.storageProviderOption,
},
{
label: "Install!",
action: this.performInstall,
},
{
label: "Cancel",
action: async function () {},
},
]);
};
storageProviderOption = async () => {
this.ui.showInfoMessage("This option is not currently available.");
await this.show();
};
performInstall = async () => {
console.log("todo");
};
}

View File

@ -1,13 +1,14 @@
export class MainMenu {
constructor(uiService) {
constructor(uiService, installMenu) {
this.ui = uiService;
this.installMenu = installMenu;
this.running = true;
}
show = async () => {
this.ui.showLogo();
this.ui.showInfoMessage("hello");
while (this.running) {
await this.promptMainMenu();
}
@ -15,22 +16,20 @@ export class MainMenu {
this.ui.showInfoMessage("K-THX-BYE");
};
promptMainMenu = async() => {
await this.ui.askMultipleChoice("Select an option",[
promptMainMenu = async () => {
await this.ui.askMultipleChoice("Select an option", [
{
label: "optionOne",
action: async function() {
console.log("A!")
}
},{
label: "optionTwo",
action: this.closeMainMenu
label: "Install Codex",
action: this.installMenu.show,
},
])
{
label: "Exit",
action: this.closeMainMenu,
},
]);
};
closeMainMenu = async() => {
console.log("B!")
closeMainMenu = async () => {
this.running = false;
};
}

51
src/ui/mainmenu.test.js Normal file
View File

@ -0,0 +1,51 @@
import { describe, beforeEach, it, expect, vi } from "vitest";
import { MainMenu } from "./mainmenu.js";
describe("mainmenu", () => {
let mainmenu;
const mockUiService = {
showLogo: vi.fn(),
showInfoMessage: vi.fn(),
askMultipleChoice: vi.fn(),
};
const mockInstallMenu = {
show: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
mainmenu = new MainMenu(mockUiService, mockInstallMenu);
// Presents test getting stuck in main loop.
const originalPrompt = mainmenu.promptMainMenu;
mainmenu.promptMainMenu = async () => {
mainmenu.running = false;
await originalPrompt();
};
});
it("shows the main menu", async () => {
await mainmenu.show();
expect(mockUiService.showLogo).toHaveBeenCalled();
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith("hello"); // example, delete this later.
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith("K-THX-BYE"); // example, delete this later.
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
"Select an option",
[
{ label: "Install Codex", action: mockInstallMenu.show },
{ label: "Exit", action: mainmenu.closeMainMenu },
],
);
});
it("sets running to false when closeMainMenu is called", async () => {
mainmenu.running = true;
await mainmenu.closeMainMenu();
expect(mainmenu.running).toEqual(false);
});
});

View File

@ -1,52 +1,49 @@
import inquirer from "inquirer";
export class NumberSelector {
constructor(uiService) {
this.uiService = uiService;
}
function getMetricsMult(valueStr, allowMetricPostfixes) {
if (!allowMetricPostfixes) return 1;
const lower = valueStr.toLowerCase();
if (lower.endsWith("tb") || lower.endsWith("t")) return Math.pow(1024, 4);
if (lower.endsWith("gb") || lower.endsWith("g")) return Math.pow(1024, 3);
if (lower.endsWith("mb") || lower.endsWith("m")) return Math.pow(1024, 2);
if (lower.endsWith("kb") || lower.endsWith("k")) return Math.pow(1024, 1);
return 1;
}
function getNumericValue(valueStr) {
try {
const num = valueStr.match(/\d+/g);
const result = parseInt(num);
if (isNaN(result) || !isFinite(result)) {
throw new Error("Invalid input received.");
showNumberSelector = async (
currentValue,
promptMessage,
allowMetricPostfixes,
) => {
try {
var valueStr = await this.promptForValueStr(promptMessage);
valueStr = valueStr.replaceAll(" ", "");
const mult = this.getMetricsMult(valueStr, allowMetricPostfixes);
const value = this.getNumericValue(valueStr);
return value * mult;
} catch {
return currentValue;
}
return result;
} catch (error) {
console.log("Failed to parse input: " + error.message);
throw error;
}
}
};
async function promptForValueStr(promptMessage) {
const response = await inquirer.prompt([
{
type: "input",
name: "valueStr",
message: promptMessage,
},
]);
return response.valueStr;
}
getMetricsMult = (valueStr, allowMetricPostfixes) => {
if (!allowMetricPostfixes) return 1;
const lower = valueStr.toLowerCase();
if (lower.endsWith("tb") || lower.endsWith("t")) return Math.pow(1024, 4);
if (lower.endsWith("gb") || lower.endsWith("g")) return Math.pow(1024, 3);
if (lower.endsWith("mb") || lower.endsWith("m")) return Math.pow(1024, 2);
if (lower.endsWith("kb") || lower.endsWith("k")) return Math.pow(1024, 1);
return 1;
};
export async function showNumberSelector(
currentValue,
promptMessage,
allowMetricPostfixes,
) {
try {
var valueStr = await promptForValueStr(promptMessage);
valueStr = valueStr.replaceAll(" ", "");
const mult = getMetricsMult(valueStr, allowMetricPostfixes);
const value = getNumericValue(valueStr);
return value * mult;
} catch {
return currentValue;
}
getNumericValue = (valueStr) => {
try {
const num = valueStr.match(/\d+/g);
const result = parseInt(num);
if (isNaN(result) || !isFinite(result)) {
throw new Error("Invalid input received.");
}
return result;
} catch (error) {
console.log("Failed to parse input: " + error.message);
throw error;
}
};
promptForValueStr = async (promptMessage) => {
return this.uiService.askPrompt(promptMessage);
};
}

View File

@ -0,0 +1,66 @@
import { describe, beforeEach, it, expect, vi } from "vitest";
import { NumberSelector } from "./numberSelector.js";
describe("number selector", () => {
let numberSelector;
const mockUiService = {
askPrompt: vi.fn(),
};
const prompt = "abc??";
beforeEach(() => {
vi.resetAllMocks();
numberSelector = new NumberSelector(mockUiService);
});
it("shows the prompt", async () => {
await numberSelector.showNumberSelector(0, prompt, false);
expect(mockUiService.askPrompt).toHaveBeenCalledWith(prompt);
});
it("returns a number given valid input", async () => {
mockUiService.askPrompt.mockResolvedValue("123");
const number = await numberSelector.showNumberSelector(0, prompt, false);
expect(number).toEqual(123);
});
it("returns the current number given invalid input", async () => {
const currentValue = 321;
mockUiService.askPrompt.mockResolvedValue("what?!");
const number = await numberSelector.showNumberSelector(
currentValue,
prompt,
false,
);
expect(number).toEqual(currentValue);
});
async function run(input) {
mockUiService.askPrompt.mockResolvedValue(input);
return await numberSelector.showNumberSelector(0, prompt, true);
}
it("allows for metric postfixes (k)", async () => {
expect(await run("1k")).toEqual(1024);
});
it("allows for metric postfixes (m)", async () => {
expect(await run("1m")).toEqual(1024 * 1024);
});
it("allows for metric postfixes (g)", async () => {
expect(await run("1g")).toEqual(1024 * 1024 * 1024);
});
it("allows for metric postfixes (t)", async () => {
expect(await run("1t")).toEqual(1024 * 1024 * 1024 * 1024);
});
});

View File

@ -1,251 +1,219 @@
import path from "path";
import inquirer from "inquirer";
import boxen from "boxen";
import chalk from "chalk";
import fs from "fs";
import { filesystemSync } from "fs-filesystem";
export class PathSelector {
constructor(uiService, fsService) {
this.ui = uiService;
this.fs = fsService;
function showMsg(msg) {
console.log(
boxen(chalk.white(msg), {
padding: 1,
margin: 1,
borderStyle: "round",
borderColor: "white",
titleAlignment: "center",
}),
);
}
function getAvailableRoots() {
const devices = filesystemSync();
var mountPoints = [];
Object.keys(devices).forEach(function (key) {
var val = devices[key];
val.volumes.forEach(function (volume) {
mountPoints.push(volume.mountPoint);
});
});
if (mountPoints.length < 1) {
throw new Error("Failed to detect file system devices.");
this.pathMustExist = true;
}
return mountPoints;
}
function splitPath(str) {
return str.replaceAll("\\", "/").split("/");
}
function dropEmptyParts(parts) {
var result = [];
parts.forEach(function (part) {
if (part.length > 0) {
result.push(part);
showPathSelector = async (startingPath, pathMustExist) => {
this.pathMustExist = pathMustExist;
this.roots = this.fs.getAvailableRoots();
this.currentPath = this.splitPath(startingPath);
if (!this.hasValidRoot(this.currentPath)) {
this.currentPath = [roots[0]];
}
});
return result;
}
while (true) {
this.showCurrent();
this.ui.askMultiChoice("Select an option:", [
{
label: "Enter path",
action: this.enterPath,
},
{
label: "Go up one",
action: this.upOne,
},
{
label: "Go down one",
action: this.downOne,
},
{
label: "Create new folder here",
action: this.createSubDir,
},
{
label: "Select this path",
action: this.selectThisPath,
},
{
label: "Cancel",
action: this.cancel,
},
]);
// var newCurrentPath = currentPath;
// switch (choice.split(".")[0]) {
// case "1":
// newCurrentPath = await enterPath(currentPath, pathMustExist);
// break;
// case "2":
// newCurrentPath = upOne(currentPath);
// break;
// case "3":
// newCurrentPath = await downOne(currentPath);
// break;
// case "4":
// newCurrentPath = await createSubDir(currentPath, pathMustExist);
// break;
// case "5":
// if (pathMustExist && !isDir(combine(currentPath))) {
// console.log("Current path does not exist.");
// break;
// } else {
// return combine(currentPath);
// }
// case "6":
// return combine(currentPath);
// }
function combine(parts) {
const toJoin = dropEmptyParts(parts);
if (toJoin.length == 1) return toJoin[0];
return path.join(...toJoin);
}
// if (hasValidRoot(roots, newCurrentPath)) {
// currentPath = newCurrentPath;
// } else {
// console.log("Selected path has no valid root.");
// }
}
};
function combineWith(parts, extra) {
const toJoin = dropEmptyParts(parts);
if (toJoin.length == 1) return path.join(toJoin[0], extra);
return path.join(...toJoin, extra);
}
splitPath = (str) => {
return str.replaceAll("\\", "/").split("/");
};
function showCurrent(currentPath) {
const len = currentPath.length;
showMsg(`Current path: [${len}]\n` + combine(currentPath));
dropEmptyParts = (parts) => {
var result = [];
parts.forEach(function (part) {
if (part.length > 0) {
result.push(part);
}
});
return result;
};
if (len < 2) {
showMsg(
"Warning - Known issue:\n" +
"Path selection does not work in root paths on some platforms.\n" +
'Use "Enter path" or "Create new folder" to navigate and create folders\n' +
"if this is the case for you.",
combine = (parts) => {
const toJoin = this.dropEmptyParts(parts);
if (toJoin.length == 1) return toJoin[0];
return this.fs.pathJoin(...toJoin);
};
combineWith = (parts, extra) => {
const toJoin = this.dropEmptyParts(parts);
if (toJoin.length == 1) return this.fs.pathJoin(toJoin[0], extra);
return this.fs.pathJoin(...toJoin, extra);
};
showCurrent = () => {
const len = this.currentPath.length;
this.ui.showInfoMessage(
`Current path: [${len}]\n` + this.combine(this.currentPath),
);
}
}
function hasValidRoot(roots, checkPath) {
if (checkPath.length < 1) return false;
var result = false;
roots.forEach(function (root) {
if (root.toLowerCase() == checkPath[0].toLowerCase()) {
console.log("valid root: " + combine(checkPath));
result = true;
if (len < 2) {
this.ui.showInfoMessage(
"Warning - Known issue:\n" +
"Path selection does not work in root paths on some platforms.\n" +
'Use "Enter path" or "Create new folder" to navigate and create folders\n' +
"if this is the case for you.",
);
}
});
if (!result) console.log("invalid root: " + combine(checkPath));
return result;
}
};
async function showMain(currentPath) {
showCurrent(currentPath);
const { choice } = await inquirer
.prompt([
{
type: "list",
name: "choice",
message: "Select an option:",
choices: [
"1. Enter path",
"2. Go up one",
"3. Go down one",
"4. Create new folder here",
"5. Select this path",
"6. Cancel",
],
pageSize: 6,
loop: true,
},
])
.catch(() => {
handleExit();
return { choice: "6" };
hasValidRoot = (checkPath) => {
if (checkPath.length < 1) return false;
var result = false;
this.roots.forEach(function (root) {
if (root.toLowerCase() == checkPath[0].toLowerCase()) {
result = true;
}
});
return result;
};
return choice;
}
export async function showPathSelector(startingPath, pathMustExist) {
const roots = getAvailableRoots();
var currentPath = splitPath(startingPath);
if (!hasValidRoot(roots, currentPath)) {
currentPath = [roots[0]];
}
while (true) {
const choice = await showMain(currentPath);
var newCurrentPath = currentPath;
switch (choice.split(".")[0]) {
case "1":
newCurrentPath = await enterPath(currentPath, pathMustExist);
break;
case "2":
newCurrentPath = upOne(currentPath);
break;
case "3":
newCurrentPath = await downOne(currentPath);
break;
case "4":
newCurrentPath = await createSubDir(currentPath, pathMustExist);
break;
case "5":
if (pathMustExist && !isDir(combine(currentPath))) {
console.log("Current path does not exist.");
break;
} else {
return combine(currentPath);
}
case "6":
return combine(currentPath);
updateCurrentIfValidFull = (newFullPath) => {
if (this.pathMustExist && !this.fs.isDir(newFullPath)) {
console.log("The path does not exist.");
}
this.updateCurrentIfValidParts(this.splitPath(newFullPath));
}
if (hasValidRoot(roots, newCurrentPath)) {
currentPath = newCurrentPath;
} else {
console.log("Selected path has no valid root.");
updateCurrentIfValidParts = (newParts) => {
if (!this.hasValidRoot(newParts)) {
console.log("The path has no valid root.");
}
}
}
async function enterPath(currentPath, pathMustExist) {
const response = await inquirer.prompt([
{
type: "input",
name: "path",
message: "Enter Path:",
},
]);
const newPath = response.path;
if (pathMustExist && !isDir(newPath)) {
console.log("The entered path does not exist.");
return currentPath;
}
return splitPath(response.path);
}
function upOne(currentPath) {
return currentPath.slice(0, currentPath.length - 1);
}
export function isDir(dir) {
try {
return fs.lstatSync(dir).isDirectory();
} catch {
return false;
}
}
function isSubDir(currentPath, entry) {
const newPath = combineWith(currentPath, entry);
return isDir(newPath);
}
function getSubDirOptions(currentPath) {
const fullPath = combine(currentPath);
const entries = fs.readdirSync(fullPath);
var result = [];
var counter = 1;
entries.forEach(function (entry) {
if (isSubDir(currentPath, entry)) {
result.push(counter + ". " + entry);
counter = counter + 1;
}
});
return result;
}
async function downOne(currentPath) {
const options = getSubDirOptions(currentPath);
if (options.length == 0) {
console.log("There are no subdirectories here.");
return currentPath;
this.currentPath = newParts;
}
const { choice } = await inquirer
.prompt([
{
type: "list",
name: "choice",
message: "Select an subdir:",
choices: options,
pageSize: options.length,
loop: true,
},
])
.catch(() => {
return currentPath;
enterPath = async () => {
const newPath = await this.ui.askPrompt("Enter Path:");
this.updateCurrentIfValidFull(newPath);
};
upOne = () => {
const newParts = this.currentPath.slice(0, this.currentPath.length - 1);
this.updateCurrentIfValidParts(newParts);
};
isSubDir = (entry) => {
const newPath = this.combineWith(this.currentPath, entry);
return this.fs.isDir(newPath);
};
getSubDirOptions = () => {
const fullPath = this.combine(this.currentPath);
const entries = this.fs.readDir(fullPath);
var result = [];
entries.forEach(function (entry) {
if (this.isSubDir(entry)) {
result.push(entry);
}
});
return result;
};
const subDir = choice.split(". ")[1];
return [...currentPath, subDir];
}
async function createSubDir(currentPath, pathMustExist) {
const response = await inquirer.prompt([
{
type: "input",
name: "name",
message: "Enter name:",
},
]);
const name = response.name;
if (name.length < 1) return;
const fullDir = combineWith(currentPath, name);
if (pathMustExist && !isDir(fullDir)) {
fs.mkdirSync(fullDir);
}
return [...currentPath, name];
downOne = async () => {
const options = this.getSubDirOptions();
if (options.length == 0) {
console.log("There are no subdirectories here.");
}
var selected = "";
const makeSelector = () => {
};
const { choice } = await inquirer
.prompt([
{
type: "list",
name: "choice",
message: "Select an subdir:",
choices: options,
pageSize: options.length,
loop: true,
},
])
.catch(() => {
return currentPath;
});
const subDir = choice.split(". ")[1];
return [...currentPath, subDir];
};
createSubDir = async (currentPath, pathMustExist) => {
const response = await inquirer.prompt([
{
type: "input",
name: "name",
message: "Enter name:",
},
]);
const name = response.name;
if (name.length < 1) return;
const fullDir = combineWith(currentPath, name);
if (pathMustExist && !isDir(fullDir)) {
// fs.mkdirSync(fullDir);
}
return [...currentPath, name];
};
}