mirror of
https://github.com/logos-storage/logos-storage-installer.git
synced 2026-01-03 05:53:06 +00:00
Merge pull request #14 from codex-storage/feature/upload-download
Merge Feature/upload with master
This commit is contained in:
commit
ebc00c237a
@ -72,6 +72,13 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## Trouble?
|
||||
|
||||
Is the installer crashing? Make sure these packages are installed:
|
||||
```
|
||||
apt-get install fdisk procps
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
1469
package-lock.json
generated
1469
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -8,24 +8,38 @@
|
||||
"codexstorage": "./index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "node index.js",
|
||||
"test": "vitest run",
|
||||
"watch": "vitest",
|
||||
"format": "prettier --write ./src"
|
||||
},
|
||||
"keywords": [
|
||||
"codex",
|
||||
"storage",
|
||||
"cli"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"author": "Codex Storage",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codex-storage/sdk-js": "^0.1.2",
|
||||
"axios": "^1.6.2",
|
||||
"boxen": "^7.1.1",
|
||||
"chalk": "^5.3.0",
|
||||
"crypto": "^1.0.1",
|
||||
"ethers": "^6.13.5",
|
||||
"fs-extra": "^11.3.0",
|
||||
"fs-filesystem": "^2.1.2",
|
||||
"inquirer": "^9.2.12",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanospinner": "^1.1.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"fs-filesystem": "^2.1.2",
|
||||
"open": "^10.1.0"
|
||||
"open": "^10.1.0",
|
||||
"ps-list": "^8.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.4.2",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/__mocks__/handler.mocks.js
Normal file
14
src/__mocks__/handler.mocks.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockInstaller = {
|
||||
isCodexInstalled: vi.fn(),
|
||||
getCodexVersion: vi.fn(),
|
||||
installCodex: vi.fn(),
|
||||
uninstallCodex: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockProcessControl = {
|
||||
getNumberOfCodexProcesses: vi.fn(),
|
||||
stopCodexProcess: vi.fn(),
|
||||
startCodexProcess: vi.fn(),
|
||||
};
|
||||
73
src/__mocks__/service.mocks.js
Normal file
73
src/__mocks__/service.mocks.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockUiService = {
|
||||
showLogo: vi.fn(),
|
||||
showInfoMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
askMultipleChoice: vi.fn(),
|
||||
askPrompt: vi.fn(),
|
||||
createAndStartSpinner: vi.fn(),
|
||||
stopSpinnerSuccess: vi.fn(),
|
||||
stopSpinnerError: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockConfigService = {
|
||||
get: vi.fn(),
|
||||
getCodexExe: vi.fn(),
|
||||
getCodexConfigFilePath: vi.fn(),
|
||||
loadConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
writeCodexConfigFile: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockFsService = {
|
||||
getAvailableRoots: vi.fn(),
|
||||
pathJoin: vi.fn(),
|
||||
isDir: vi.fn(),
|
||||
isFile: vi.fn(),
|
||||
readDir: vi.fn(),
|
||||
makeDir: vi.fn(),
|
||||
moveDir: vi.fn(),
|
||||
deleteDir: vi.fn(),
|
||||
readJsonFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeJsonFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
ensureDirExists: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockShellService = {
|
||||
run: vi.fn(),
|
||||
spawnDetachedProcess: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockOsService = {
|
||||
isWindows: vi.fn(),
|
||||
isDarwin: vi.fn(),
|
||||
isLinux: vi.fn(),
|
||||
getWorkingDir: vi.fn(),
|
||||
listProcesses: vi.fn(),
|
||||
stopProcess: vi.fn(),
|
||||
terminateProcess: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockCodexGlobals = {
|
||||
getPublicIp: vi.fn(),
|
||||
getTestnetSPRs: vi.fn(),
|
||||
getEthProvider: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockCodexApp = {
|
||||
openCodexApp: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockMarketplaceSetup = {
|
||||
runClientWizard: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockDataService = {
|
||||
upload: vi.fn(),
|
||||
download: vi.fn(),
|
||||
debugInfo: vi.fn(),
|
||||
localData: vi.fn(),
|
||||
};
|
||||
19
src/__mocks__/ui.mocks.js
Normal file
19
src/__mocks__/ui.mocks.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockInstallMenu = {
|
||||
show: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockConfigMenu = {
|
||||
show: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockDataMenu = {
|
||||
performUpload: vi.fn(),
|
||||
performDownload: vi.fn(),
|
||||
showLocalData: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockNodeStatusMenu = {
|
||||
showNodeStatus: vi.fn(),
|
||||
};
|
||||
20
src/__mocks__/utils.mocks.js
Normal file
20
src/__mocks__/utils.mocks.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockPathSelector = {
|
||||
show: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockNumberSelector = {
|
||||
show: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockMenuLoop = {
|
||||
initialize: vi.fn(),
|
||||
showOnce: vi.fn(),
|
||||
showLoop: vi.fn(),
|
||||
stopLoop: vi.fn(),
|
||||
};
|
||||
|
||||
export const mockDataDirMover = {
|
||||
moveDataDir: vi.fn(),
|
||||
};
|
||||
@ -1,35 +1,41 @@
|
||||
import { showErrorMessage } from '../utils/messages.js';
|
||||
import { showErrorMessage } from "../utils/messages.js";
|
||||
|
||||
export function handleCommandLineOperation() {
|
||||
return process.argv.length > 2;
|
||||
return process.argv.length > 2;
|
||||
}
|
||||
|
||||
export function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) return null;
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) return null;
|
||||
|
||||
switch (args[0]) {
|
||||
case '--upload':
|
||||
if (args.length !== 2) {
|
||||
console.log(showErrorMessage('Usage: npx codexstorage --upload <filename>'));
|
||||
process.exit(1);
|
||||
}
|
||||
return { command: 'upload', value: args[1] };
|
||||
|
||||
case '--download':
|
||||
if (args.length !== 2) {
|
||||
console.log(showErrorMessage('Usage: npx codexstorage --download <cid>'));
|
||||
process.exit(1);
|
||||
}
|
||||
return { command: 'download', value: args[1] };
|
||||
|
||||
default:
|
||||
console.log(showErrorMessage(
|
||||
'Invalid command. Available commands:\n\n' +
|
||||
'npx codexstorage\n' +
|
||||
'npx codexstorage --upload <filename>\n' +
|
||||
'npx codexstorage --download <cid>'
|
||||
));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
switch (args[0]) {
|
||||
case "--upload":
|
||||
if (args.length !== 2) {
|
||||
console.log(
|
||||
showErrorMessage("Usage: npx codexstorage --upload <filename>"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { command: "upload", value: args[1] };
|
||||
|
||||
case "--download":
|
||||
if (args.length !== 2) {
|
||||
console.log(
|
||||
showErrorMessage("Usage: npx codexstorage --download <cid>"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { command: "download", value: args[1] };
|
||||
|
||||
default:
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
"Invalid command. Available commands:\n\n" +
|
||||
"npx codexstorage\n" +
|
||||
"npx codexstorage --upload <filename>\n" +
|
||||
"npx codexstorage --download <cid>",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { showErrorMessage, showInfoMessage } from './utils/messages.js';
|
||||
import { isDir, showPathSelector } from './utils/pathSelector.js';
|
||||
import { saveConfig } from './services/config.js';
|
||||
import { showNumberSelector } from './utils/numberSelector.js';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
function bytesAmountToString(numBytes) {
|
||||
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
var value = numBytes;
|
||||
var index = 0;
|
||||
while (value > 1024) {
|
||||
index = index + 1;
|
||||
value = value / 1024;
|
||||
}
|
||||
|
||||
if (index == 0) return `${numBytes} Bytes`;
|
||||
return `${numBytes} Bytes (${value} ${units[index]})`;
|
||||
}
|
||||
|
||||
async function showStorageQuotaSelector(config) {
|
||||
console.log(showInfoMessage('You can use: "GB" or "gb", etc.'));
|
||||
const result = await showNumberSelector(config.storageQuota, "Storage quota", true);
|
||||
if (result < (100 * 1024 * 1024)) {
|
||||
console.log(showErrorMessage("Storage quote should be >= 100mb."));
|
||||
return config.storageQuota;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function showConfigMenu(config) {
|
||||
var newDataDir = config.dataDir;
|
||||
try {
|
||||
while (true) {
|
||||
console.log(showInfoMessage("Codex Configuration"));
|
||||
const { choice } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Select to edit:',
|
||||
choices: [
|
||||
`1. Data path = "${newDataDir}"`,
|
||||
`2. Logs path = "${config.logsDir}"`,
|
||||
`3. Storage quota = ${bytesAmountToString(config.storageQuota)}`,
|
||||
`4. Discovery port = ${config.ports.discPort}`,
|
||||
`5. P2P listen port = ${config.ports.listenPort}`,
|
||||
`6. API port = ${config.ports.apiPort}`,
|
||||
'7. Save changes and exit',
|
||||
'8. Discard changes and exit'
|
||||
],
|
||||
pageSize: 8,
|
||||
loop: true
|
||||
}
|
||||
]).catch(() => {
|
||||
return;
|
||||
});
|
||||
|
||||
switch (choice.split('.')[0]) {
|
||||
case '1':
|
||||
newDataDir = await showPathSelector(config.dataDir, false);
|
||||
if (isDir(newDataDir)) {
|
||||
console.log(showInfoMessage("Warning: The new data path already exists. Make sure you know what you're doing."));
|
||||
}
|
||||
break;
|
||||
case '2':
|
||||
config.logsDir = await showPathSelector(config.logsDir, true);
|
||||
break;
|
||||
case '3':
|
||||
config.storageQuota = await showStorageQuotaSelector(config);
|
||||
break;
|
||||
case '4':
|
||||
config.ports.discPort = await showNumberSelector(config.ports.discPort, "Discovery Port (UDP)", false);
|
||||
break;
|
||||
case '5':
|
||||
config.ports.listenPort = await showNumberSelector(config.ports.listenPort, "Listen Port (TCP)", false);
|
||||
break;
|
||||
case '6':
|
||||
config.ports.apiPort = await showNumberSelector(config.ports.apiPort, "API Port (TCP)", false);
|
||||
break;
|
||||
case '7':
|
||||
// save changes, back to main menu
|
||||
config = updateDataDir(config, newDataDir);
|
||||
saveConfig(config);
|
||||
return;
|
||||
case '8':
|
||||
// discard changes, back to main menu
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red('An error occurred:', error.message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDataDir(config, newDataDir) {
|
||||
if (config.dataDir == newDataDir) return config;
|
||||
|
||||
// The Codex dataDir is a little strange:
|
||||
// If the old one is empty: The new one should not exist, so that codex creates it
|
||||
// with the correct security permissions.
|
||||
// If the old one does exist: We move it.
|
||||
|
||||
if (isDir(config.dataDir)) {
|
||||
console.log(showInfoMessage(
|
||||
'Moving Codex data folder...\n' +
|
||||
`From: "${config.dataDir}"\n` +
|
||||
`To: "${newDataDir}"`
|
||||
));
|
||||
|
||||
try {
|
||||
fs.moveSync(config.dataDir, newDataDir);
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage("Error while moving dataDir: " + error.message));
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Old data dir does not exist.
|
||||
if (isDir(newDataDir)) {
|
||||
console.log(showInfoMessage(
|
||||
"Warning: the selected data path already exists.\n" +
|
||||
`New data path = "${newDataDir}"\n` +
|
||||
"Codex may overwrite data in this folder.\n" +
|
||||
"Codex will fail to start if this folder does not have the required\n" +
|
||||
"security permissions."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
config.dataDir = newDataDir;
|
||||
return config;
|
||||
}
|
||||
@ -16,4 +16,4 @@ export const ASCII_ART = `
|
||||
+--------------------------------------------------------------------+
|
||||
| Docs : docs.codex.storage | Discord : discord.gg/codex-storage |
|
||||
+--------------------------------------------------------------------+
|
||||
`;
|
||||
`;
|
||||
|
||||
@ -1,197 +1,263 @@
|
||||
import { createSpinner } from 'nanospinner';
|
||||
import { runCommand } from '../utils/command.js';
|
||||
import { showErrorMessage, showInfoMessage, showSuccessMessage } from '../utils/messages.js';
|
||||
import { isNodeRunning } from '../services/nodeService.js';
|
||||
import fs from 'fs/promises';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import path from 'path';
|
||||
import mime from 'mime-types';
|
||||
import axios from 'axios';
|
||||
import { createSpinner } from "nanospinner";
|
||||
import { runCommand } from "../utils/command.js";
|
||||
import {
|
||||
showErrorMessage,
|
||||
showInfoMessage,
|
||||
showSuccessMessage,
|
||||
} from "../utils/messages.js";
|
||||
import { isNodeRunning } from "../services/nodeService.js";
|
||||
import fs from "fs/promises";
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import inquirer from "inquirer";
|
||||
import path from "path";
|
||||
import mime from "mime-types";
|
||||
import axios from "axios";
|
||||
|
||||
export async function uploadFile(config, filePath = null, handleCommandLineOperation, showNavigationMenu) {
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(showErrorMessage('Codex node is not running. Try again after starting the node'));
|
||||
return handleCommandLineOperation() ? process.exit(1) : showNavigationMenu();
|
||||
}
|
||||
export async function uploadFile(
|
||||
config,
|
||||
filePath = null,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
) {
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
"Codex node is not running. Try again after starting the node",
|
||||
),
|
||||
);
|
||||
return handleCommandLineOperation()
|
||||
? process.exit(1)
|
||||
: showNavigationMenu();
|
||||
}
|
||||
|
||||
console.log(boxen(
|
||||
chalk.yellow('⚠️ Codex does not encrypt files. Anything uploaded will be available publicly on testnet.\nThe testnet does not provide any guarantees - please do not use in production.'),
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'yellow',
|
||||
title: '⚠️ Warning',
|
||||
titleAlignment: 'center'
|
||||
}
|
||||
));
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.yellow(
|
||||
"⚠️ Codex does not encrypt files. Anything uploaded will be available publicly on testnet.\nThe testnet does not provide any guarantees - please do not use in production.",
|
||||
),
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "yellow",
|
||||
title: "⚠️ Warning",
|
||||
titleAlignment: "center",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let fileToUpload = filePath;
|
||||
if (!fileToUpload) {
|
||||
const { inputPath } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'inputPath',
|
||||
message: 'Enter the file path to upload:',
|
||||
validate: input => input.length > 0
|
||||
}
|
||||
]);
|
||||
fileToUpload = inputPath;
|
||||
}
|
||||
let fileToUpload = filePath;
|
||||
if (!fileToUpload) {
|
||||
const { inputPath } = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "inputPath",
|
||||
message: "Enter the file path to upload:",
|
||||
validate: (input) => input.length > 0,
|
||||
},
|
||||
]);
|
||||
fileToUpload = inputPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(fileToUpload);
|
||||
|
||||
const filename = path.basename(fileToUpload);
|
||||
const contentType = mime.lookup(fileToUpload) || "application/octet-stream";
|
||||
|
||||
const spinner = createSpinner("Uploading file").start();
|
||||
try {
|
||||
await fs.access(fileToUpload);
|
||||
|
||||
const filename = path.basename(fileToUpload);
|
||||
const contentType = mime.lookup(fileToUpload) || 'application/octet-stream';
|
||||
|
||||
const spinner = createSpinner('Uploading file').start();
|
||||
try {
|
||||
const result = await runCommand(
|
||||
`curl -X POST http://localhost:${config.ports.apiPort}/api/codex/v1/data ` +
|
||||
`-H 'Content-Type: ${contentType}' ` +
|
||||
`-H 'Content-Disposition: attachment; filename="${filename}"' ` +
|
||||
`-w '\\n' -T "${fileToUpload}"`
|
||||
);
|
||||
spinner.success();
|
||||
console.log(showSuccessMessage('Successfully uploaded!\n\nCID: ' + result.trim()));
|
||||
} catch (error) {
|
||||
spinner.error();
|
||||
throw new Error(`Failed to upload: ${error.message}`);
|
||||
}
|
||||
const result = await runCommand(
|
||||
`curl -X POST http://localhost:${config.ports.apiPort}/api/codex/v1/data ` +
|
||||
`-H 'Content-Type: ${contentType}' ` +
|
||||
`-H 'Content-Disposition: attachment; filename="${filename}"' ` +
|
||||
`-w '\\n' -T "${fileToUpload}"`,
|
||||
);
|
||||
spinner.success();
|
||||
console.log(
|
||||
showSuccessMessage("Successfully uploaded!\n\nCID: " + result.trim()),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage(error.code === 'ENOENT'
|
||||
? `File not found: ${fileToUpload}`
|
||||
: `Error uploading file: ${error.message}`));
|
||||
spinner.error();
|
||||
throw new Error(`Failed to upload: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
error.code === "ENOENT"
|
||||
? `File not found: ${fileToUpload}`
|
||||
: `Error uploading file: ${error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return handleCommandLineOperation() ? process.exit(0) : showNavigationMenu();
|
||||
return handleCommandLineOperation() ? process.exit(0) : showNavigationMenu();
|
||||
}
|
||||
|
||||
export async function downloadFile(config, cid = null, handleCommandLineOperation, showNavigationMenu) {
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(showErrorMessage('Codex node is not running. Try again after starting the node'));
|
||||
return handleCommandLineOperation() ? process.exit(1) : showNavigationMenu();
|
||||
}
|
||||
export async function downloadFile(
|
||||
config,
|
||||
cid = null,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
) {
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
"Codex node is not running. Try again after starting the node",
|
||||
),
|
||||
);
|
||||
return handleCommandLineOperation()
|
||||
? process.exit(1)
|
||||
: showNavigationMenu();
|
||||
}
|
||||
|
||||
let cidToDownload = cid;
|
||||
if (!cidToDownload) {
|
||||
const { inputCid } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'inputCid',
|
||||
message: 'Enter the CID:',
|
||||
validate: input => input.length > 0
|
||||
}
|
||||
]);
|
||||
cidToDownload = inputCid;
|
||||
}
|
||||
let cidToDownload = cid;
|
||||
if (!cidToDownload) {
|
||||
const { inputCid } = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "inputCid",
|
||||
message: "Enter the CID:",
|
||||
validate: (input) => input.length > 0,
|
||||
},
|
||||
]);
|
||||
cidToDownload = inputCid;
|
||||
}
|
||||
|
||||
try {
|
||||
const spinner = createSpinner("Fetching file metadata...").start();
|
||||
try {
|
||||
const spinner = createSpinner('Fetching file metadata...').start();
|
||||
try {
|
||||
// First, get the file metadata
|
||||
const metadataResponse = await axios.post(`http://localhost:${config.ports.apiPort}/api/codex/v1/data/${cidToDownload}/network`);
|
||||
const { manifest } = metadataResponse.data;
|
||||
const { filename, mimetype } = manifest;
|
||||
// First, get the file metadata
|
||||
const metadataResponse = await axios.post(
|
||||
`http://localhost:${config.ports.apiPort}/api/codex/v1/data/${cidToDownload}/network`,
|
||||
);
|
||||
const { manifest } = metadataResponse.data;
|
||||
const { filename, mimetype } = manifest;
|
||||
|
||||
spinner.success();
|
||||
spinner.start('Downloading file...');
|
||||
spinner.success();
|
||||
spinner.start("Downloading file...");
|
||||
|
||||
// Then download the file with the correct filename
|
||||
await runCommand(`curl "http://localhost:${config.ports.apiPort}/api/codex/v1/data/${cidToDownload}/network/stream" -o "${filename}"`);
|
||||
|
||||
spinner.success();
|
||||
console.log(showSuccessMessage(
|
||||
'Successfully downloaded!\n\n' +
|
||||
`Filename: ${filename}\n` +
|
||||
`Type: ${mimetype}`
|
||||
));
|
||||
// Then download the file with the correct filename
|
||||
await runCommand(
|
||||
`curl "http://localhost:${config.ports.apiPort}/api/codex/v1/data/${cidToDownload}/network/stream" -o "${filename}"`,
|
||||
);
|
||||
|
||||
// Show file details
|
||||
console.log(boxen(
|
||||
`${chalk.cyan('File Details')}\n\n` +
|
||||
`${chalk.cyan('Filename:')} ${filename}\n` +
|
||||
`${chalk.cyan('MIME Type:')} ${mimetype}\n` +
|
||||
`${chalk.cyan('CID:')} ${cidToDownload}\n` +
|
||||
`${chalk.cyan('Protected:')} ${manifest.protected ? chalk.green('Yes') : chalk.red('No')}\n` +
|
||||
`${chalk.cyan('Uploaded:')} ${new Date(manifest.uploadedAt * 1000).toLocaleString()}`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'blue',
|
||||
title: '📁 Download Complete',
|
||||
titleAlignment: 'center'
|
||||
}
|
||||
));
|
||||
} catch (error) {
|
||||
spinner.error();
|
||||
if (error.response) {
|
||||
throw new Error(`Failed to download: ${error.response.data.message || 'File not found'}`);
|
||||
} else {
|
||||
throw new Error(`Failed to download: ${error.message}`);
|
||||
}
|
||||
}
|
||||
spinner.success();
|
||||
console.log(
|
||||
showSuccessMessage(
|
||||
"Successfully downloaded!\n\n" +
|
||||
`Filename: ${filename}\n` +
|
||||
`Type: ${mimetype}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Show file details
|
||||
console.log(
|
||||
boxen(
|
||||
`${chalk.cyan("File Details")}\n\n` +
|
||||
`${chalk.cyan("Filename:")} ${filename}\n` +
|
||||
`${chalk.cyan("MIME Type:")} ${mimetype}\n` +
|
||||
`${chalk.cyan("CID:")} ${cidToDownload}\n` +
|
||||
`${chalk.cyan("Protected:")} ${manifest.protected ? chalk.green("Yes") : chalk.red("No")}\n` +
|
||||
`${chalk.cyan("Uploaded:")} ${new Date(manifest.uploadedAt * 1000).toLocaleString()}`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "blue",
|
||||
title: "📁 Download Complete",
|
||||
titleAlignment: "center",
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage(`Error downloading file: ${error.message}`));
|
||||
spinner.error();
|
||||
if (error.response) {
|
||||
throw new Error(
|
||||
`Failed to download: ${error.response.data.message || "File not found"}`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Failed to download: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage(`Error downloading file: ${error.message}`));
|
||||
}
|
||||
|
||||
return handleCommandLineOperation() ? process.exit(0) : showNavigationMenu();
|
||||
return handleCommandLineOperation() ? process.exit(0) : showNavigationMenu();
|
||||
}
|
||||
|
||||
export async function showLocalFiles(config, showNavigationMenu) {
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(showErrorMessage('Codex node is not running. Try again after starting the node'));
|
||||
await showNavigationMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const spinner = createSpinner('Fetching local files...').start();
|
||||
const filesResponse = await axios.get(`http://localhost:${config.ports.apiPort}/api/codex/v1/data`);
|
||||
const filesData = filesResponse.data;
|
||||
spinner.success();
|
||||
|
||||
if (filesData.content && filesData.content.length > 0) {
|
||||
console.log(showInfoMessage(`Found ${filesData.content.length} local file(s)`));
|
||||
|
||||
filesData.content.forEach((file, index) => {
|
||||
const { cid, manifest } = file;
|
||||
const { rootHash, originalBytes, blockSize, protected: isProtected, filename, mimetype, uploadedAt } = manifest;
|
||||
|
||||
const uploadedDate = new Date(uploadedAt * 1000).toLocaleString();
|
||||
const fileSize = (originalBytes / 1024).toFixed(2);
|
||||
|
||||
console.log(boxen(
|
||||
`${chalk.cyan('File')} ${index + 1} of ${filesData.content.length}\n\n` +
|
||||
`${chalk.cyan('Filename:')} ${filename}\n` +
|
||||
`${chalk.cyan('CID:')} ${cid}\n` +
|
||||
`${chalk.cyan('Size:')} ${fileSize} KB\n` +
|
||||
`${chalk.cyan('MIME Type:')} ${mimetype}\n` +
|
||||
`${chalk.cyan('Uploaded:')} ${uploadedDate}\n` +
|
||||
`${chalk.cyan('Protected:')} ${isProtected ? chalk.green('Yes') : chalk.red('No')}`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'blue',
|
||||
title: `📁 File Details`,
|
||||
titleAlignment: 'center'
|
||||
}
|
||||
));
|
||||
});
|
||||
} else {
|
||||
console.log(showInfoMessage("Node contains no datasets."));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage(`Failed to fetch local files: ${error.message}`));
|
||||
}
|
||||
|
||||
const nodeRunning = await isNodeRunning(config);
|
||||
if (!nodeRunning) {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
"Codex node is not running. Try again after starting the node",
|
||||
),
|
||||
);
|
||||
await showNavigationMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const spinner = createSpinner("Fetching local files...").start();
|
||||
const filesResponse = await axios.get(
|
||||
`http://localhost:${config.ports.apiPort}/api/codex/v1/data`,
|
||||
);
|
||||
const filesData = filesResponse.data;
|
||||
spinner.success();
|
||||
|
||||
if (filesData.content && filesData.content.length > 0) {
|
||||
console.log(
|
||||
showInfoMessage(`Found ${filesData.content.length} local file(s)`),
|
||||
);
|
||||
|
||||
filesData.content.forEach((file, index) => {
|
||||
const { cid, manifest } = file;
|
||||
const {
|
||||
rootHash,
|
||||
originalBytes,
|
||||
blockSize,
|
||||
protected: isProtected,
|
||||
filename,
|
||||
mimetype,
|
||||
uploadedAt,
|
||||
} = manifest;
|
||||
|
||||
const uploadedDate = new Date(uploadedAt * 1000).toLocaleString();
|
||||
const fileSize = (originalBytes / 1024).toFixed(2);
|
||||
|
||||
console.log(
|
||||
boxen(
|
||||
`${chalk.cyan("File")} ${index + 1} of ${filesData.content.length}\n\n` +
|
||||
`${chalk.cyan("Filename:")} ${filename}\n` +
|
||||
`${chalk.cyan("CID:")} ${cid}\n` +
|
||||
`${chalk.cyan("Size:")} ${fileSize} KB\n` +
|
||||
`${chalk.cyan("MIME Type:")} ${mimetype}\n` +
|
||||
`${chalk.cyan("Uploaded:")} ${uploadedDate}\n` +
|
||||
`${chalk.cyan("Protected:")} ${isProtected ? chalk.green("Yes") : chalk.red("No")}`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "blue",
|
||||
title: `📁 File Details`,
|
||||
titleAlignment: "center",
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(showInfoMessage("Node contains no datasets."));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
showErrorMessage(`Failed to fetch local files: ${error.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
await showNavigationMenu();
|
||||
}
|
||||
|
||||
@ -1,103 +1,118 @@
|
||||
import path from 'path';
|
||||
import inquirer from 'inquirer';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { createSpinner } from 'nanospinner';
|
||||
import { runCommand } from '../utils/command.js';
|
||||
import { showErrorMessage, showInfoMessage, showSuccessMessage } from '../utils/messages.js';
|
||||
import { checkDependencies } from '../services/nodeService.js';
|
||||
import { saveConfig } from '../services/config.js';
|
||||
import { getCodexRootPath, getCodexBinPath } from '../utils/appdata.js';
|
||||
import path from "path";
|
||||
import inquirer from "inquirer";
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
import { createSpinner } from "nanospinner";
|
||||
import { runCommand } from "../utils/command.js";
|
||||
import {
|
||||
showErrorMessage,
|
||||
showInfoMessage,
|
||||
showSuccessMessage,
|
||||
} from "../utils/messages.js";
|
||||
import { checkDependencies } from "../services/nodeService.js";
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
async function showPrivacyDisclaimer() {
|
||||
const disclaimer = boxen(`
|
||||
${chalk.yellow.bold('Privacy Disclaimer')}
|
||||
const disclaimer = boxen(
|
||||
`
|
||||
${chalk.yellow.bold("Privacy Disclaimer")}
|
||||
|
||||
Codex is currently in testnet and to make your testnet experience better, we are currently tracking some of your node and network information such as:
|
||||
|
||||
${chalk.cyan('- Node ID')}
|
||||
${chalk.cyan('- Peer ID')}
|
||||
${chalk.cyan('- Public IP address')}
|
||||
${chalk.cyan('- Codex node version')}
|
||||
${chalk.cyan('- Number of connected peers')}
|
||||
${chalk.cyan('- Discovery and listening ports')}
|
||||
${chalk.cyan("- Node ID")}
|
||||
${chalk.cyan("- Peer ID")}
|
||||
${chalk.cyan("- Public IP address")}
|
||||
${chalk.cyan("- Codex node version")}
|
||||
${chalk.cyan("- Number of connected peers")}
|
||||
${chalk.cyan("- Discovery and listening ports")}
|
||||
|
||||
These information will be used for calculating various metrics that can eventually make the Codex experience better. Please agree to the following disclaimer to continue using the Codex Storage CLI or alternatively, use the manual setup instructions at docs.codex.storage.
|
||||
`, {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'yellow',
|
||||
title: '📋 IMPORTANT',
|
||||
titleAlignment: 'center'
|
||||
});
|
||||
`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "double",
|
||||
borderColor: "yellow",
|
||||
title: "📋 IMPORTANT",
|
||||
titleAlignment: "center",
|
||||
},
|
||||
);
|
||||
|
||||
console.log(disclaimer);
|
||||
console.log(disclaimer);
|
||||
|
||||
const { agreement } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'agreement',
|
||||
message: 'Do you agree to the privacy disclaimer? (y/n):',
|
||||
validate: (input) => {
|
||||
const lowercased = input.toLowerCase();
|
||||
if (lowercased === 'y' || lowercased === 'n') {
|
||||
return true;
|
||||
}
|
||||
return 'Please enter either y or n';
|
||||
}
|
||||
const { agreement } = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "agreement",
|
||||
message: "Do you agree to the privacy disclaimer? (y/n):",
|
||||
validate: (input) => {
|
||||
const lowercased = input.toLowerCase();
|
||||
if (lowercased === "y" || lowercased === "n") {
|
||||
return true;
|
||||
}
|
||||
]);
|
||||
return "Please enter either y or n";
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return agreement.toLowerCase() === 'y';
|
||||
return agreement.toLowerCase() === "y";
|
||||
}
|
||||
|
||||
export async function getCodexVersion(config) {
|
||||
if (config.codexExe.length < 1) return "";
|
||||
if (config.codexExe.length < 1) return "";
|
||||
|
||||
try {
|
||||
const version = await runCommand(`"${config.codexExe}" --version`);
|
||||
if (version.length < 1) throw new Error("Version info not found.");
|
||||
return version;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const version = await runCommand(`"${config.codexExe}" --version`);
|
||||
if (version.length < 1) throw new Error("Version info not found.");
|
||||
return version;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCodex(config, showNavigationMenu) {
|
||||
const version = await getCodexVersion(config);
|
||||
const version = await getCodexVersion(config);
|
||||
|
||||
if (version.length > 0) {
|
||||
console.log(chalk.green('Codex is already installed. Version:'));
|
||||
console.log(chalk.green(version));
|
||||
await showNavigationMenu();
|
||||
return false;
|
||||
} else {
|
||||
console.log(chalk.cyanBright('Codex is not installed, proceeding with installation...'));
|
||||
return await performInstall(config);
|
||||
}
|
||||
if (version.length > 0) {
|
||||
console.log(chalk.green("Codex is already installed. Version:"));
|
||||
console.log(chalk.green(version));
|
||||
await showNavigationMenu();
|
||||
return false;
|
||||
} else {
|
||||
console.log(
|
||||
chalk.cyanBright(
|
||||
"Codex is not installed, proceeding with installation...",
|
||||
),
|
||||
);
|
||||
return await performInstall(config);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCodexExePath(config, codexExePath) {
|
||||
config.codexExe = codexExePath;
|
||||
if (!fs.existsSync(config.codexExe)) {
|
||||
console.log(showErrorMessage(`Codex executable not found in expected path: ${config.codexExe}`));
|
||||
throw new Error("Exe not found");
|
||||
}
|
||||
if (await getCodexVersion(config).length < 1) {
|
||||
console.log(showInfoMessage("no"));
|
||||
throw new Error(`Codex not found at path after install. Path: '${config.codexExe}'`);
|
||||
}
|
||||
saveConfig(config);
|
||||
config.codexExe = codexExePath;
|
||||
if (!fs.existsSync(config.codexExe)) {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
`Codex executable not found in expected path: ${config.codexExe}`,
|
||||
),
|
||||
);
|
||||
throw new Error("Exe not found");
|
||||
}
|
||||
if ((await getCodexVersion(config).length) < 1) {
|
||||
console.log(showInfoMessage("no"));
|
||||
throw new Error(
|
||||
`Codex not found at path after install. Path: '${config.codexExe}'`,
|
||||
);
|
||||
}
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
async function clearCodexExePathFromConfig(config) {
|
||||
config.codexExe = "";
|
||||
saveConfig(config);
|
||||
config.codexExe = "";
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
async function configureShellPath(installPath) {
|
||||
@ -141,58 +156,67 @@ async function configureShellPath(installPath) {
|
||||
}
|
||||
|
||||
async function performInstall(config) {
|
||||
const agreed = await showPrivacyDisclaimer();
|
||||
if (!agreed) {
|
||||
console.log(showInfoMessage('You can find manual setup instructions at docs.codex.storage'));
|
||||
process.exit(0);
|
||||
}
|
||||
const agreed = await showPrivacyDisclaimer();
|
||||
if (!agreed) {
|
||||
console.log(
|
||||
showInfoMessage(
|
||||
"You can find manual setup instructions at docs.codex.storage",
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const installPath = getCodexBinPath();
|
||||
console.log(showInfoMessage("Install location: " + installPath));
|
||||
const installPath = getCodexBinPath();
|
||||
console.log(showInfoMessage("Install location: " + installPath));
|
||||
|
||||
const spinner = createSpinner('Installing Codex...').start();
|
||||
const spinner = createSpinner("Installing Codex...").start();
|
||||
|
||||
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.');
|
||||
}
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
await runCommand(
|
||||
"curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd",
|
||||
);
|
||||
await runCommand(
|
||||
`set "INSTALL_DIR=${installPath}" && "${process.cwd()}\\install.cmd"`,
|
||||
);
|
||||
|
||||
await runCommand('curl -LO --ssl-no-revoke https://get.codex.storage/install.cmd');
|
||||
await runCommand(`set "INSTALL_DIR=${installPath}" && "${process.cwd()}\\install.cmd"`);
|
||||
|
||||
await saveCodexExePath(config, path.join(installPath, "codex.exe"));
|
||||
await saveCodexExePath(config, path.join(installPath, "codex.exe"));
|
||||
|
||||
try {
|
||||
await runCommand('del /f install.cmd');
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('Access is denied')) {
|
||||
throw new Error('Installation failed. Please run the command prompt as Administrator and try again.');
|
||||
} else if (error.message.includes('curl')) {
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
throw new Error(`Installation failed: "${error.message}"`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await runCommand("del /f install.cmd");
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes("Access is denied")) {
|
||||
throw new Error(
|
||||
"Installation failed. Please run the command prompt as Administrator and try again.",
|
||||
);
|
||||
} else if (error.message.includes("curl")) {
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
try {
|
||||
const dependenciesInstalled = await checkDependencies();
|
||||
if (!dependenciesInstalled) {
|
||||
console.log(showInfoMessage('Please install the required dependencies and try again.'));
|
||||
throw new Error("Missing dependencies.");
|
||||
}
|
||||
throw new Error(`Installation failed: "${error.message}"`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const dependenciesInstalled = await checkDependencies();
|
||||
if (!dependenciesInstalled) {
|
||||
console.log(
|
||||
showInfoMessage(
|
||||
"Please install the required dependencies and try again.",
|
||||
),
|
||||
);
|
||||
throw new Error("Missing dependencies.");
|
||||
}
|
||||
|
||||
const downloadCommand = 'curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh';
|
||||
await runCommand(downloadCommand);
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const timeoutCommand = `perl -e '
|
||||
const downloadCommand =
|
||||
"curl -# --connect-timeout 10 --max-time 60 -L https://get.codex.storage/install.sh -o install.sh && chmod +x install.sh";
|
||||
await runCommand(downloadCommand);
|
||||
|
||||
if (platform === "darwin") {
|
||||
const timeoutCommand = `perl -e '
|
||||
eval {
|
||||
local $SIG{ALRM} = sub { die "timeout\\n" };
|
||||
alarm(120);
|
||||
@ -310,26 +334,83 @@ export async function uninstallCodex(config, showNavigationMenu) {
|
||||
),
|
||||
default: false
|
||||
}
|
||||
]);
|
||||
|
||||
if (!confirm) {
|
||||
console.log(showInfoMessage('Uninstall cancelled.'));
|
||||
await showNavigationMenu();
|
||||
return;
|
||||
} finally {
|
||||
await runCommand("rm -f install.sh").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
removeDir(getCodexRootPath());
|
||||
clearCodexExePathFromConfig(config);
|
||||
const version = await getCodexVersion(config);
|
||||
console.log(chalk.green(version));
|
||||
|
||||
console.log(showSuccessMessage('Codex has been successfully uninstalled.'));
|
||||
await showNavigationMenu();
|
||||
console.log(
|
||||
showSuccessMessage(
|
||||
"Codex is successfully installed!\n" +
|
||||
`Install path: "${config.codexExe}"\n\n` +
|
||||
"The default configuration should work for most platforms.\n" +
|
||||
"Please review the configuration before starting Codex.\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.log(showInfoMessage('Codex binary not found, nothing to uninstall.'));
|
||||
} else {
|
||||
console.log(showErrorMessage('Failed to uninstall Codex. Please make sure Codex is installed before trying to remove it.'));
|
||||
}
|
||||
await showNavigationMenu();
|
||||
throw new Error(
|
||||
"Installation completed but Codex command is not available. Please restart your terminal and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
showInfoMessage("Please review the configuration before starting Codex."),
|
||||
);
|
||||
|
||||
spinner.success();
|
||||
return true;
|
||||
} catch (error) {
|
||||
spinner.error();
|
||||
console.log(showErrorMessage(`Failed to install Codex: ${error.message}`));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function uninstallCodex(config, showNavigationMenu) {
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "confirm",
|
||||
message: chalk.yellow(
|
||||
"⚠️ Are you sure you want to uninstall Codex? This action cannot be undone. \n" +
|
||||
"All data stored in the local Codex node will be deleted as well.",
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirm) {
|
||||
console.log(showInfoMessage("Uninstall cancelled."));
|
||||
await showNavigationMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
removeDir(getCodexRootPath());
|
||||
clearCodexExePathFromConfig(config);
|
||||
|
||||
console.log(showSuccessMessage("Codex has been successfully uninstalled."));
|
||||
await showNavigationMenu();
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
console.log(
|
||||
showInfoMessage("Codex binary not found, nothing to uninstall."),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
showErrorMessage(
|
||||
"Failed to uninstall Codex. Please make sure Codex is installed before trying to remove it.",
|
||||
),
|
||||
);
|
||||
}
|
||||
await showNavigationMenu();
|
||||
}
|
||||
}
|
||||
|
||||
133
src/handlers/installer.js
Normal file
133
src/handlers/installer.js
Normal file
@ -0,0 +1,133 @@
|
||||
export class Installer {
|
||||
constructor(
|
||||
configService,
|
||||
shellService,
|
||||
osService,
|
||||
fsService,
|
||||
marketplaceSetup,
|
||||
) {
|
||||
this.config = configService.get();
|
||||
this.configService = configService;
|
||||
this.shell = shellService;
|
||||
this.os = osService;
|
||||
this.fs = fsService;
|
||||
this.market = marketplaceSetup;
|
||||
}
|
||||
|
||||
isCodexInstalled = async () => {
|
||||
try {
|
||||
await this.getCodexVersion();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
getCodexVersion = async () => {
|
||||
const codexExe = this.configService.getCodexExe();
|
||||
if (!this.fs.isFile(codexExe)) throw new Error("Codex not installed.");
|
||||
const version = await this.shell.run(`"${codexExe}" --version`);
|
||||
if (version.length < 1) throw new Error("Version info not found.");
|
||||
return version;
|
||||
};
|
||||
|
||||
installCodex = async (processCallbacks) => {
|
||||
this.fs.ensureDirExists(this.config.codexRoot);
|
||||
if (!(await this.arePrerequisitesCorrect(processCallbacks))) return;
|
||||
|
||||
if (!(await this.market.runClientWizard())) return;
|
||||
|
||||
processCallbacks.installStarts();
|
||||
if (this.os.isWindows()) {
|
||||
await this.installCodexWindows(processCallbacks);
|
||||
} else {
|
||||
await this.installCodexUnix(processCallbacks);
|
||||
}
|
||||
|
||||
if (!(await this.isCodexInstalled())) {
|
||||
processCallbacks.warn("Codex failed to install.");
|
||||
throw new Error("Codex installation failed.");
|
||||
}
|
||||
processCallbacks.installSuccessful();
|
||||
};
|
||||
|
||||
uninstallCodex = () => {
|
||||
this.fs.deleteDir(this.config.codexRoot);
|
||||
};
|
||||
|
||||
arePrerequisitesCorrect = async (processCallbacks) => {
|
||||
if (await this.isCodexInstalled()) {
|
||||
processCallbacks.warn("Codex is already installed.");
|
||||
return false;
|
||||
}
|
||||
if (!this.fs.isDir(this.config.codexRoot)) {
|
||||
processCallbacks.warn("Root path doesn't exist.");
|
||||
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.codexRoot}" && ` +
|
||||
`"${this.os.getWorkingDir()}\\install.cmd"`,
|
||||
);
|
||||
await this.shell.run("del /f install.cmd");
|
||||
};
|
||||
|
||||
installCodexUnix = async (processCallbacks) => {
|
||||
if (!(await this.ensureUnixDependencies(processCallbacks))) return;
|
||||
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.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.codexRoot}\\" bash install.sh");
|
||||
alarm(0);
|
||||
};
|
||||
die if $@;
|
||||
'`;
|
||||
await this.shell.run(timeoutCommand);
|
||||
};
|
||||
|
||||
runInstallerLinux = async () => {
|
||||
await this.shell.run(
|
||||
`INSTALL_DIR="${this.config.codexRoot}" 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;
|
||||
};
|
||||
}
|
||||
400
src/handlers/installer.test.js
Normal file
400
src/handlers/installer.test.js
Normal file
@ -0,0 +1,400 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import {
|
||||
mockShellService,
|
||||
mockOsService,
|
||||
mockFsService,
|
||||
mockConfigService,
|
||||
mockMarketplaceSetup,
|
||||
} from "../__mocks__/service.mocks.js";
|
||||
import { Installer } from "./installer.js";
|
||||
|
||||
describe("Installer", () => {
|
||||
const config = {
|
||||
codexRoot: "/codex-root",
|
||||
};
|
||||
const workingDir = "/working-dir";
|
||||
const exe = "abc.exe";
|
||||
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);
|
||||
mockConfigService.getCodexExe.mockReturnValue(exe);
|
||||
|
||||
installer = new Installer(
|
||||
mockConfigService,
|
||||
mockShellService,
|
||||
mockOsService,
|
||||
mockFsService,
|
||||
mockMarketplaceSetup,
|
||||
);
|
||||
});
|
||||
|
||||
describe("getCodexVersion", () => {
|
||||
it("checks if the codex exe file exists", async () => {
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
mockShellService.run.mockResolvedValueOnce("a");
|
||||
await installer.getCodexVersion();
|
||||
expect(mockFsService.isFile).toHaveBeenCalledWith(exe);
|
||||
});
|
||||
|
||||
it("throws when codex exe is not a file", async () => {
|
||||
mockFsService.isFile.mockReturnValue(false);
|
||||
await expect(installer.getCodexVersion()).rejects.toThrow(
|
||||
"Codex not installed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when version info is not found", async () => {
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
mockShellService.run.mockResolvedValueOnce("");
|
||||
await expect(installer.getCodexVersion()).rejects.toThrow(
|
||||
"Version info not found.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns version info", async () => {
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
const versionInfo = "versionInfo";
|
||||
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("ensures codex root dir exists", async () => {
|
||||
installer.arePrerequisitesCorrect.mockResolvedValue(false);
|
||||
await installer.installCodex(processCallbacks);
|
||||
|
||||
expect(mockFsService.ensureDirExists).toHaveBeenCalledWith(
|
||||
config.codexRoot,
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("returns early when marketplace client wizard returns false", async () => {
|
||||
installer.arePrerequisitesCorrect.mockResolvedValue(true);
|
||||
mockMarketplaceSetup.runClientWizard.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);
|
||||
mockMarketplaceSetup.runClientWizard.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("warns user when codex is not installed after installation", async () => {
|
||||
installer.isCodexInstalled.mockResolvedValue(false);
|
||||
await expect(installer.installCodex(processCallbacks)).rejects.toThrow(
|
||||
"Codex installation failed.",
|
||||
);
|
||||
expect(processCallbacks.warn).toHaveBeenCalledWith(
|
||||
"Codex failed to install.",
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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("checks if the root path exists", async () => {
|
||||
expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mockFsService.isDir).toHaveBeenCalledWith(config.codexRoot);
|
||||
});
|
||||
|
||||
it("returns false when root path does not exist", async () => {
|
||||
mockFsService.isDir.mockReturnValue(false);
|
||||
expect(await installer.arePrerequisitesCorrect(processCallbacks)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(processCallbacks.warn).toHaveBeenCalledWith(
|
||||
"Root path doesn't exist.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when curl is not available", async () => {
|
||||
installer.isCodexInstalled.mockResolvedValue(false);
|
||||
mockFsService.isDir.mockReturnValue(true);
|
||||
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);
|
||||
mockFsService.isDir.mockReturnValue(true);
|
||||
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.codexRoot}" && "${workingDir}\\install.cmd"`,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
processCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns early if unix dependencies are not met", async () => {
|
||||
installer.ensureUnixDependencies.mockResolvedValue(false);
|
||||
|
||||
await installer.installCodexUnix(processCallbacks);
|
||||
|
||||
expect(processCallbacks.downloadSuccessful).not.toHaveBeenCalled();
|
||||
expect(installer.runInstallerDarwin).not.toHaveBeenCalled();
|
||||
expect(installer.runInstallerLinux).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when dependencies are met", () => {
|
||||
beforeEach(() => {
|
||||
installer.ensureUnixDependencies.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
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("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.codexRoot}\\" 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.codexRoot}" 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("uninstallCodex", () => {
|
||||
it("deletes the codex root path", () => {
|
||||
installer.uninstallCodex();
|
||||
|
||||
expect(mockFsService.deleteDir).toHaveBeenCalledWith(config.codexRoot);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/handlers/processControl.js
Normal file
85
src/handlers/processControl.js
Normal file
@ -0,0 +1,85 @@
|
||||
export class ProcessControl {
|
||||
constructor(configService, shellService, osService, fsService, codexGlobals) {
|
||||
this.configService = configService;
|
||||
this.shell = shellService;
|
||||
this.os = osService;
|
||||
this.fs = fsService;
|
||||
this.codexGlobals = codexGlobals;
|
||||
}
|
||||
|
||||
getCodexProcesses = async () => {
|
||||
const processes = await this.os.listProcesses();
|
||||
if (this.os.isWindows()) {
|
||||
return processes.filter((p) => p.name === "codex.exe");
|
||||
} else {
|
||||
return processes.filter(
|
||||
(p) => p.name === "codex" && !p.cmd.includes("<defunct>"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
getNumberOfCodexProcesses = async () => {
|
||||
return (await this.getCodexProcesses()).length;
|
||||
};
|
||||
|
||||
stopCodexProcess = async () => {
|
||||
const processes = await this.getCodexProcesses();
|
||||
if (processes.length < 1) throw new Error("No codex process found");
|
||||
|
||||
const pid = processes[0].pid;
|
||||
await this.stopProcess(pid);
|
||||
};
|
||||
|
||||
stopProcess = async (pid) => {
|
||||
this.os.stopProcess(pid);
|
||||
await this.sleep();
|
||||
|
||||
if (await this.isProcessRunning(pid)) {
|
||||
this.os.terminateProcess(pid);
|
||||
await this.sleep();
|
||||
}
|
||||
};
|
||||
|
||||
isProcessRunning = async (pid) => {
|
||||
const processes = await this.os.listProcesses();
|
||||
const p = processes.filter((p) => p.pid == pid);
|
||||
const result = p.length > 0;
|
||||
return result;
|
||||
};
|
||||
|
||||
startCodexProcess = async () => {
|
||||
await this.saveCodexConfigFile();
|
||||
await this.startCodex();
|
||||
await this.sleep();
|
||||
};
|
||||
|
||||
sleep = async () => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
saveCodexConfigFile = async () => {
|
||||
const publicIp = await this.codexGlobals.getPublicIp();
|
||||
const bootstrapNodes = await this.codexGlobals.getTestnetSPRs();
|
||||
this.configService.writeCodexConfigFile(publicIp, bootstrapNodes);
|
||||
};
|
||||
|
||||
startCodex = async () => {
|
||||
const executable = this.configService.getCodexExe();
|
||||
const workingDir = this.configService.get().codexRoot;
|
||||
const args = [
|
||||
`--config-file=${this.configService.getCodexConfigFilePath()}`,
|
||||
|
||||
// Marketplace client parameters cannot be set via config file.
|
||||
// Open issue: https://github.com/codex-storage/nim-codex/issues/1206
|
||||
// So we're setting them here.
|
||||
"persistence",
|
||||
`--eth-provider=${this.codexGlobals.getEthProvider()}`,
|
||||
`--eth-private-key=eth.key`, // duplicated in configService.
|
||||
];
|
||||
await this.shell.spawnDetachedProcess(executable, workingDir, args);
|
||||
};
|
||||
}
|
||||
209
src/handlers/processControl.test.js
Normal file
209
src/handlers/processControl.test.js
Normal file
@ -0,0 +1,209 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import {
|
||||
mockShellService,
|
||||
mockOsService,
|
||||
mockFsService,
|
||||
mockCodexGlobals,
|
||||
} from "../__mocks__/service.mocks.js";
|
||||
import { mockConfigService } from "../__mocks__/service.mocks.js";
|
||||
import { ProcessControl } from "./processControl.js";
|
||||
|
||||
describe("ProcessControl", () => {
|
||||
let processControl;
|
||||
const mockEthProvider = "mockEthProvider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockCodexGlobals.getEthProvider.mockReturnValue(mockEthProvider);
|
||||
|
||||
processControl = new ProcessControl(
|
||||
mockConfigService,
|
||||
mockShellService,
|
||||
mockOsService,
|
||||
mockFsService,
|
||||
mockCodexGlobals,
|
||||
);
|
||||
|
||||
processControl.sleep = vi.fn();
|
||||
});
|
||||
|
||||
describe("getCodexProcesses", () => {
|
||||
const processes = [
|
||||
{ id: 0, name: "a.exe", cmd: "" },
|
||||
{ id: 1, name: "codex", cmd: "<defunct>" },
|
||||
{ id: 2, name: "codex", cmd: "" },
|
||||
{ id: 3, name: "codex.exe", cmd: "<defunct>" },
|
||||
{ id: 4, name: "notcodex", cmd: "" },
|
||||
{ id: 5, name: "alsonotcodex.exe", cmd: "" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockOsService.listProcesses.mockResolvedValue(processes);
|
||||
});
|
||||
|
||||
it("returns codex.exe processes on windows", async () => {
|
||||
mockOsService.isWindows.mockReturnValue(true);
|
||||
|
||||
const p = await processControl.getCodexProcesses();
|
||||
|
||||
expect(p.length).toBe(1);
|
||||
expect(p[0]).toBe(processes[3]);
|
||||
});
|
||||
|
||||
it("returns codex processes on non-windows", async () => {
|
||||
mockOsService.isWindows.mockReturnValue(false);
|
||||
|
||||
const p = await processControl.getCodexProcesses();
|
||||
|
||||
expect(p.length).toBe(1);
|
||||
expect(p[0]).toBe(processes[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNumberOfCodexProcesses", () => {
|
||||
it("counts the results of getCodexProcesses", async () => {
|
||||
processControl.getCodexProcesses = vi.fn();
|
||||
processControl.getCodexProcesses.mockResolvedValue(["a", "b", "c"]);
|
||||
|
||||
expect(await processControl.getNumberOfCodexProcesses()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopCodexProcess", () => {
|
||||
beforeEach(() => {
|
||||
processControl.getCodexProcesses = vi.fn();
|
||||
});
|
||||
|
||||
it("throws when no codex processes are found", async () => {
|
||||
processControl.getCodexProcesses.mockResolvedValue([]);
|
||||
|
||||
await expect(processControl.stopCodexProcess).rejects.toThrow(
|
||||
"No codex process found",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls stopProcess with pid of first codex process", async () => {
|
||||
const pid = 12345;
|
||||
processControl.getCodexProcesses.mockResolvedValue([
|
||||
{ pid: pid },
|
||||
{ pid: 111 },
|
||||
{ pid: 222 },
|
||||
]);
|
||||
|
||||
processControl.stopProcess = vi.fn();
|
||||
await processControl.stopCodexProcess();
|
||||
|
||||
expect(processControl.stopProcess).toHaveBeenCalledWith(pid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopProcess", () => {
|
||||
const pid = 234;
|
||||
beforeEach(() => {
|
||||
processControl.isProcessRunning = vi.fn();
|
||||
});
|
||||
|
||||
it("stops the process", async () => {
|
||||
processControl.isProcessRunning.mockResolvedValue(false);
|
||||
|
||||
await processControl.stopProcess(pid);
|
||||
|
||||
expect(mockOsService.stopProcess).toHaveBeenCalledWith(pid);
|
||||
});
|
||||
|
||||
it("sleeps", async () => {
|
||||
processControl.isProcessRunning.mockResolvedValue(false);
|
||||
|
||||
await processControl.stopProcess(pid);
|
||||
|
||||
expect(processControl.sleep).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminates process if it is running after stop", async () => {
|
||||
processControl.isProcessRunning.mockResolvedValue(true);
|
||||
|
||||
await processControl.stopProcess(pid);
|
||||
|
||||
expect(mockOsService.terminateProcess).toHaveBeenCalledWith(pid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessRunning", () => {
|
||||
const pid = 345;
|
||||
|
||||
it("is true when process is in process list", async () => {
|
||||
mockOsService.listProcesses.mockResolvedValue([{ pid: pid }]);
|
||||
|
||||
expect(await processControl.isProcessRunning(pid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is false when process is not in process list", async () => {
|
||||
mockOsService.listProcesses.mockResolvedValue([{ pid: pid + 11 }]);
|
||||
|
||||
expect(await processControl.isProcessRunning(pid)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startCodexProcess", () => {
|
||||
beforeEach(() => {
|
||||
processControl.saveCodexConfigFile = vi.fn();
|
||||
processControl.startCodex = vi.fn();
|
||||
});
|
||||
|
||||
it("saves the config, starts codex, and sleeps", async () => {
|
||||
await processControl.startCodexProcess();
|
||||
|
||||
expect(processControl.saveCodexConfigFile).toHaveBeenCalled();
|
||||
expect(processControl.startCodex).toHaveBeenCalled();
|
||||
expect(processControl.sleep).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveCodexConfigFile", () => {
|
||||
const publicIp = "1.2.3.4";
|
||||
const bootNodes = ["a", "b", "c"];
|
||||
|
||||
beforeEach(() => {
|
||||
mockCodexGlobals.getPublicIp.mockResolvedValue(publicIp);
|
||||
mockCodexGlobals.getTestnetSPRs.mockResolvedValue(bootNodes);
|
||||
});
|
||||
|
||||
it("writes codex config file using public IP and testnet bootstrap nodes", async () => {
|
||||
await processControl.saveCodexConfigFile();
|
||||
|
||||
expect(mockConfigService.writeCodexConfigFile).toHaveBeenCalledWith(
|
||||
publicIp,
|
||||
bootNodes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startCodex", () => {
|
||||
const config = {
|
||||
codexRoot: "/codex-root",
|
||||
};
|
||||
const exe = "abc.exe";
|
||||
const configFile = "/codex/config.toml";
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfigService.getCodexExe.mockReturnValue(exe);
|
||||
mockConfigService.get.mockReturnValue(config);
|
||||
mockConfigService.getCodexConfigFilePath.mockReturnValue(configFile);
|
||||
});
|
||||
|
||||
it("spawns a detached codex process in the codex root working directory with the config file as argument", async () => {
|
||||
await processControl.startCodex();
|
||||
|
||||
expect(mockShellService.spawnDetachedProcess).toHaveBeenCalledWith(
|
||||
exe,
|
||||
config.codexRoot,
|
||||
[
|
||||
`--config-file=${configFile}`,
|
||||
"persistence",
|
||||
`--eth-provider=${mockEthProvider}`,
|
||||
`--eth-private-key=eth.key`, // duplicated in configService.
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
401
src/main.js
401
src/main.js
@ -1,155 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
import { ASCII_ART } from './constants/ascii.js';
|
||||
import { handleCommandLineOperation, parseCommandLineArgs } from './cli/commandParser.js';
|
||||
import { uploadFile, downloadFile, showLocalFiles } from './handlers/fileHandlers.js';
|
||||
import { installCodex, uninstallCodex } from './handlers/installationHandlers.js';
|
||||
import { runCodex, checkNodeStatus } from './handlers/nodeHandlers.js';
|
||||
import { showInfoMessage } from './utils/messages.js';
|
||||
import { loadConfig } from './services/config.js';
|
||||
import { showConfigMenu } from './configmenu.js';
|
||||
import { openCodexApp } from './services/codexapp.js';
|
||||
import inquirer from "inquirer";
|
||||
import chalk from "chalk";
|
||||
import boxen from "boxen";
|
||||
import { ASCII_ART } from "./constants/ascii.js";
|
||||
import {
|
||||
handleCommandLineOperation,
|
||||
parseCommandLineArgs,
|
||||
} from "./cli/commandParser.js";
|
||||
import {
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
showLocalFiles,
|
||||
} from "./handlers/fileHandlers.js";
|
||||
import {
|
||||
installCodex,
|
||||
uninstallCodex,
|
||||
} from "./handlers/installationHandlers.js";
|
||||
import { runCodex, checkNodeStatus } from "./handlers/nodeHandlers.js";
|
||||
import { showInfoMessage } from "./utils/messages.js";
|
||||
import { ConfigService } from "./services/configService.js";
|
||||
import { UiService } from "./services/uiService.js";
|
||||
import { FsService } from "./services/fsService.js";
|
||||
import { MainMenu } from "./ui/mainMenu.js";
|
||||
import { InstallMenu } from "./ui/installMenu.js";
|
||||
import { ConfigMenu } from "./ui/configMenu.js";
|
||||
import { PathSelector } from "./utils/pathSelector.js";
|
||||
import { NumberSelector } from "./utils/numberSelector.js";
|
||||
import { MenuLoop } from "./utils/menuLoop.js";
|
||||
import { Installer } from "./handlers/installer.js";
|
||||
import { ShellService } from "./services/shellService.js";
|
||||
import { OsService } from "./services/osService.js";
|
||||
import { ProcessControl } from "./handlers/processControl.js";
|
||||
import { CodexGlobals } from "./services/codexGlobals.js";
|
||||
import { CodexApp } from "./services/codexApp.js";
|
||||
import { EthersService } from "./services/ethersService.js";
|
||||
import { MarketplaceSetup } from "./ui/marketplaceSetup.js";
|
||||
import { DataService } from "./services/dataService.js";
|
||||
import { DataMenu } from "./ui/dataMenu.js";
|
||||
import { NodeStatusMenu } from "./ui/nodeStatusMenu.js";
|
||||
|
||||
async function showNavigationMenu() {
|
||||
console.log('\n')
|
||||
const { choice } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'What would you like to do?',
|
||||
choices: [
|
||||
'1. Back to main menu',
|
||||
'2. Exit'
|
||||
],
|
||||
pageSize: 2,
|
||||
loop: true
|
||||
}
|
||||
]);
|
||||
console.log("\n");
|
||||
const { choice } = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "choice",
|
||||
message: "What would you like to do?",
|
||||
choices: ["1. Back to main menu", "2. Exit"],
|
||||
pageSize: 2,
|
||||
loop: true,
|
||||
},
|
||||
]);
|
||||
|
||||
switch (choice.split('.')[0]) {
|
||||
case '1':
|
||||
return main();
|
||||
case '2':
|
||||
handleExit();
|
||||
}
|
||||
switch (choice.split(".")[0]) {
|
||||
case "1":
|
||||
return main();
|
||||
case "2":
|
||||
handleExit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExit() {
|
||||
console.log(boxen(
|
||||
chalk.cyanBright('👋 Thank you for using Codex Storage CLI! Goodbye!'),
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'cyan',
|
||||
title: '👋 GOODBYE',
|
||||
titleAlignment: 'center'
|
||||
}
|
||||
));
|
||||
process.exit(0);
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.cyanBright("👋 Thank you for using Codex Storage CLI! Goodbye!"),
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "cyan",
|
||||
title: "👋 GOODBYE",
|
||||
titleAlignment: "center",
|
||||
},
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const commandArgs = parseCommandLineArgs();
|
||||
if (commandArgs) {
|
||||
switch (commandArgs.command) {
|
||||
case 'upload':
|
||||
await uploadFile(commandArgs.value, handleCommandLineOperation, showNavigationMenu);
|
||||
return;
|
||||
case 'download':
|
||||
await downloadFile(commandArgs.value, handleCommandLineOperation, showNavigationMenu);
|
||||
return;
|
||||
}
|
||||
const commandArgs = parseCommandLineArgs();
|
||||
if (commandArgs) {
|
||||
switch (commandArgs.command) {
|
||||
case "upload":
|
||||
await uploadFile(
|
||||
commandArgs.value,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
);
|
||||
return;
|
||||
case "download":
|
||||
await downloadFile(
|
||||
commandArgs.value,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
process.on('SIGQUIT', handleExit);
|
||||
|
||||
try {
|
||||
const config = loadConfig();
|
||||
while (true) {
|
||||
console.log('\n' + chalk.cyanBright(ASCII_ART));
|
||||
const { choice } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Select an option:',
|
||||
choices: [
|
||||
'1. Download and install Codex',
|
||||
'2. Run Codex node',
|
||||
'3. Check node status',
|
||||
'4. Edit Codex configuration',
|
||||
'5. Open Codex App',
|
||||
'6. Upload a file',
|
||||
'7. Download a file',
|
||||
'8. Show local data',
|
||||
'9. Uninstall Codex node',
|
||||
'10. Submit feedback',
|
||||
'11. Exit'
|
||||
],
|
||||
pageSize: 11,
|
||||
loop: true
|
||||
}
|
||||
]).catch(() => {
|
||||
handleExit();
|
||||
return;
|
||||
});
|
||||
|
||||
switch (choice.split('.')[0]) {
|
||||
case '1':
|
||||
const installed = await installCodex(config, showNavigationMenu);
|
||||
if (installed) {
|
||||
await showConfigMenu(config);
|
||||
}
|
||||
break;
|
||||
case '2':
|
||||
await runCodex(config, showNavigationMenu);
|
||||
return;
|
||||
case '3':
|
||||
await checkNodeStatus(config, showNavigationMenu);
|
||||
break;
|
||||
case '4':
|
||||
await showConfigMenu(config);
|
||||
break;
|
||||
case '5':
|
||||
openCodexApp(config);
|
||||
break;
|
||||
case '6':
|
||||
await uploadFile(config, null, handleCommandLineOperation, showNavigationMenu);
|
||||
break;
|
||||
case '7':
|
||||
await downloadFile(config, null, handleCommandLineOperation, showNavigationMenu);
|
||||
break;
|
||||
case '8':
|
||||
await showLocalFiles(config, showNavigationMenu);
|
||||
break;
|
||||
case '9':
|
||||
await uninstallCodex(config, showNavigationMenu);
|
||||
break;
|
||||
case '10':
|
||||
const { exec } = await import('child_process');
|
||||
const url = 'https://tally.so/r/w2DlXb';
|
||||
const command = process.platform === 'win32' ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`;
|
||||
exec(command);
|
||||
console.log(showInfoMessage('Opening feedback form in your browser...'));
|
||||
break;
|
||||
case '11':
|
||||
handleExit();
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", handleExit);
|
||||
process.on("SIGTERM", handleExit);
|
||||
process.on("SIGQUIT", handleExit);
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('ExitPromptError')) {
|
||||
handleExit();
|
||||
} else {
|
||||
console.error(chalk.red('An error occurred:', error.message));
|
||||
handleExit();
|
||||
}
|
||||
const codexGlobals = new CodexGlobals();
|
||||
const uiService = new UiService();
|
||||
const fsService = new FsService();
|
||||
const shellService = new ShellService();
|
||||
const osService = new OsService();
|
||||
const numberSelector = new NumberSelector(uiService);
|
||||
const configService = new ConfigService(fsService, osService);
|
||||
const codexApp = new CodexApp(configService);
|
||||
const pathSelector = new PathSelector(uiService, new MenuLoop(), fsService);
|
||||
const ethersService = new EthersService(
|
||||
fsService,
|
||||
configService,
|
||||
osService,
|
||||
shellService,
|
||||
);
|
||||
const marketplaceSetup = new MarketplaceSetup(
|
||||
uiService,
|
||||
configService,
|
||||
ethersService,
|
||||
);
|
||||
const installer = new Installer(
|
||||
configService,
|
||||
shellService,
|
||||
osService,
|
||||
fsService,
|
||||
marketplaceSetup,
|
||||
);
|
||||
const installMenu = new InstallMenu(
|
||||
uiService,
|
||||
new MenuLoop(),
|
||||
configService,
|
||||
pathSelector,
|
||||
installer,
|
||||
);
|
||||
const configMenu = new ConfigMenu(
|
||||
uiService,
|
||||
new MenuLoop(),
|
||||
configService,
|
||||
numberSelector,
|
||||
);
|
||||
const processControl = new ProcessControl(
|
||||
configService,
|
||||
shellService,
|
||||
osService,
|
||||
fsService,
|
||||
codexGlobals,
|
||||
);
|
||||
const dataService = new DataService(configService);
|
||||
const dataMenu = new DataMenu(uiService, fsService, dataService);
|
||||
const nodeStatusMenu = new NodeStatusMenu(
|
||||
uiService,
|
||||
dataService,
|
||||
new MenuLoop(),
|
||||
);
|
||||
const mainMenu = new MainMenu(
|
||||
uiService,
|
||||
new MenuLoop(),
|
||||
installMenu,
|
||||
configMenu,
|
||||
installer,
|
||||
processControl,
|
||||
codexApp,
|
||||
dataMenu,
|
||||
nodeStatusMenu,
|
||||
);
|
||||
|
||||
await mainMenu.show();
|
||||
return;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
console.log("\n" + chalk.cyanBright(ASCII_ART));
|
||||
const { choice } = await inquirer
|
||||
.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "choice",
|
||||
message: "Select an option:",
|
||||
choices: [
|
||||
"1. Download and install Codex",
|
||||
"2. Run Codex node",
|
||||
"3. Check node status",
|
||||
"4. Edit Codex configuration",
|
||||
"5. Open Codex App",
|
||||
"6. Upload a file",
|
||||
"7. Download a file",
|
||||
"8. Show local data",
|
||||
"9. Uninstall Codex node",
|
||||
"10. Submit feedback",
|
||||
"11. Exit",
|
||||
],
|
||||
pageSize: 11,
|
||||
loop: true,
|
||||
},
|
||||
])
|
||||
.catch(() => {
|
||||
handleExit();
|
||||
return;
|
||||
});
|
||||
|
||||
switch (choice.split(".")[0]) {
|
||||
case "1":
|
||||
const installed = await installCodex(config, showNavigationMenu);
|
||||
if (installed) {
|
||||
await showConfigMenu(config);
|
||||
}
|
||||
break;
|
||||
case "2":
|
||||
await runCodex(config, showNavigationMenu);
|
||||
return;
|
||||
case "3":
|
||||
await checkNodeStatus(config, showNavigationMenu);
|
||||
break;
|
||||
case "4":
|
||||
await showConfigMenu(config);
|
||||
break;
|
||||
case "5":
|
||||
openCodexApp(config);
|
||||
break;
|
||||
case "6":
|
||||
await uploadFile(
|
||||
config,
|
||||
null,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
);
|
||||
break;
|
||||
case "7":
|
||||
await downloadFile(
|
||||
config,
|
||||
null,
|
||||
handleCommandLineOperation,
|
||||
showNavigationMenu,
|
||||
);
|
||||
break;
|
||||
case "8":
|
||||
await showLocalFiles(config, showNavigationMenu);
|
||||
break;
|
||||
case "9":
|
||||
await uninstallCodex(config, showNavigationMenu);
|
||||
break;
|
||||
case "10":
|
||||
const { exec } = await import("child_process");
|
||||
const url = "https://tally.so/r/w2DlXb";
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? `start ${url}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${url}`
|
||||
: `xdg-open ${url}`;
|
||||
exec(command);
|
||||
console.log(
|
||||
showInfoMessage("Opening feedback form in your browser..."),
|
||||
);
|
||||
break;
|
||||
case "11":
|
||||
handleExit();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes("ExitPromptError")) {
|
||||
handleExit();
|
||||
} else {
|
||||
console.error(chalk.red("An error occurred:", error.message));
|
||||
handleExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
src/services/codexApp.js
Normal file
22
src/services/codexApp.js
Normal file
@ -0,0 +1,22 @@
|
||||
import open from "open";
|
||||
|
||||
export class CodexApp {
|
||||
constructor(configService) {
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
openCodexApp = async () => {
|
||||
// TODO: Update this to the main URL when the PR for adding api-port query parameter support
|
||||
// has been merged and deployed.
|
||||
// See: https://github.com/codex-storage/codex-marketplace-ui/issues/92
|
||||
|
||||
const segments = [
|
||||
"https://releases-v0-0-14.codex-marketplace-ui.pages.dev/",
|
||||
"?",
|
||||
`api-port=${this.configService.get().ports.apiPort}`,
|
||||
];
|
||||
|
||||
const url = segments.join("");
|
||||
open(url);
|
||||
};
|
||||
}
|
||||
17
src/services/codexGlobals.js
Normal file
17
src/services/codexGlobals.js
Normal file
@ -0,0 +1,17 @@
|
||||
import axios from "axios";
|
||||
|
||||
export class CodexGlobals {
|
||||
getPublicIp = async () => {
|
||||
const result = (await axios.get(`https://ip.codex.storage`)).data;
|
||||
return result.replaceAll("\n", "");
|
||||
};
|
||||
|
||||
getTestnetSPRs = async () => {
|
||||
const result = (await axios.get(`https://spr.codex.storage/testnet`)).data;
|
||||
return result.split("\n").filter((line) => line.length > 0);
|
||||
};
|
||||
|
||||
getEthProvider = () => {
|
||||
return "https://rpc.testnet.codex.storage";
|
||||
};
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import open from 'open';
|
||||
|
||||
export function openCodexApp(config) {
|
||||
// TODO: Update this to the main URL when the PR for adding api-port query parameter support
|
||||
// has been merged and deployed.
|
||||
// See: https://github.com/codex-storage/codex-marketplace-ui/issues/92
|
||||
|
||||
const segments = [
|
||||
'https://releases-v0-0-14.codex-marketplace-ui.pages.dev/',
|
||||
'?',
|
||||
`api-port=${config.ports.apiPort}`
|
||||
]
|
||||
|
||||
const url = segments.join("");
|
||||
|
||||
open(url);
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getAppDataDir } from '../utils/appdata.js';
|
||||
import { getCodexDataDirDefaultPath, getCodexLogsDefaultPath } from '../utils/appdata.js';
|
||||
|
||||
const defaultConfig = {
|
||||
codexExe: "",
|
||||
// User-selected config options:
|
||||
dataDir: getCodexDataDirDefaultPath(),
|
||||
logsDir: getCodexLogsDefaultPath(),
|
||||
storageQuota: 8 * 1024 * 1024 * 1024,
|
||||
ports: {
|
||||
discPort: 8090,
|
||||
listenPort: 8070,
|
||||
apiPort: 8080
|
||||
}
|
||||
};
|
||||
|
||||
function getConfigFilename() {
|
||||
return path.join(getAppDataDir(), "config.json");
|
||||
}
|
||||
|
||||
export function saveConfig(config) {
|
||||
const filePath = getConfigFilename();
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(config));
|
||||
} catch (error) {
|
||||
console.error(`Failed to save config file to '${filePath}' error: '${error}'.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig() {
|
||||
const filePath = getConfigFilename();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
saveConfig(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath));
|
||||
} catch (error) {
|
||||
console.error(`Failed to load config file from '${filePath}' error: '${error}'.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
116
src/services/configService.js
Normal file
116
src/services/configService.js
Normal file
@ -0,0 +1,116 @@
|
||||
import { getAppDataDir, getDefaultCodexRootPath } from "../utils/appData.js";
|
||||
|
||||
const defaultConfig = {
|
||||
codexRoot: getDefaultCodexRootPath(),
|
||||
storageQuota: 8 * 1024 * 1024 * 1024,
|
||||
ports: {
|
||||
discPort: 8090,
|
||||
listenPort: 8070,
|
||||
apiPort: 8080,
|
||||
},
|
||||
};
|
||||
|
||||
const datadir = "datadir";
|
||||
const codexLogFile = "codex.log";
|
||||
const codexConfigFile = "config.toml";
|
||||
const ethKeyFile = "eth.key";
|
||||
const ethAddressFile = "eth.address";
|
||||
|
||||
export class ConfigService {
|
||||
constructor(fsService, osService) {
|
||||
this.fs = fsService;
|
||||
this.os = osService;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
get = () => {
|
||||
return this.config;
|
||||
};
|
||||
|
||||
getCodexExe = () => {
|
||||
var codexExe = "codex";
|
||||
if (this.os.isWindows()) {
|
||||
codexExe = "codex.exe";
|
||||
}
|
||||
|
||||
return this.fs.pathJoin([this.config.codexRoot, codexExe]);
|
||||
};
|
||||
|
||||
getCodexConfigFilePath = () => {
|
||||
return this.fs.pathJoin([this.config.codexRoot, codexConfigFile]);
|
||||
};
|
||||
|
||||
getEthFilePaths = () => {
|
||||
return {
|
||||
key: this.fs.pathJoin([this.config.codexRoot, ethKeyFile]),
|
||||
address: this.fs.pathJoin([this.config.codexRoot, ethAddressFile]),
|
||||
};
|
||||
};
|
||||
|
||||
loadConfig = () => {
|
||||
const filePath = this.getConfigFilename();
|
||||
try {
|
||||
if (!this.fs.isFile(filePath)) {
|
||||
this.config = defaultConfig;
|
||||
this.saveConfig();
|
||||
} else {
|
||||
this.config = this.fs.readJsonFile(filePath);
|
||||
|
||||
if (this.config.codexRoot == undefined) {
|
||||
this.config = defaultConfig;
|
||||
this.saveConfig();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load config file from '${filePath}' error: '${error}'.`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
saveConfig = () => {
|
||||
const filePath = this.getConfigFilename();
|
||||
try {
|
||||
this.fs.writeJsonFile(filePath, this.config);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to save config file to '${filePath}' error: '${error}'.`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
getConfigFilename = () => {
|
||||
return this.fs.pathJoin([getAppDataDir(), "config.json"]);
|
||||
};
|
||||
|
||||
validateConfiguration = () => {
|
||||
if (this.config.storageQuota < 1024 * 1024 * 100)
|
||||
throw new Error("Storage quota must be at least 100MB");
|
||||
};
|
||||
|
||||
writeCodexConfigFile = (publicIp, bootstrapNodes, ethProvider) => {
|
||||
this.validateConfiguration();
|
||||
|
||||
const nl = "\n";
|
||||
const bootNodes = bootstrapNodes.map((v) => `"${v}"`).join(",");
|
||||
|
||||
this.fs.writeFile(
|
||||
this.getCodexConfigFilePath(),
|
||||
`data-dir="${datadir}"${nl}` +
|
||||
`log-level="DEBUG"${nl}` +
|
||||
`log-file="${codexLogFile}"${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}]${nl}` +
|
||||
// Marketplace client parameters cannot be set via config file.
|
||||
// Open issue: https://github.com/codex-storage/nim-codex/issues/1206
|
||||
"",
|
||||
);
|
||||
};
|
||||
}
|
||||
231
src/services/configService.test.js
Normal file
231
src/services/configService.test.js
Normal file
@ -0,0 +1,231 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { ConfigService } from "./configService.js";
|
||||
import { mockFsService, mockOsService } from "../__mocks__/service.mocks.js";
|
||||
import { getAppDataDir, getDefaultCodexRootPath } from "../utils/appData.js";
|
||||
|
||||
function getDefaultConfig() {
|
||||
return {
|
||||
codexRoot: getDefaultCodexRootPath(),
|
||||
storageQuota: 8 * 1024 * 1024 * 1024,
|
||||
ports: {
|
||||
discPort: 8090,
|
||||
listenPort: 8070,
|
||||
apiPort: 8080,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConfigService", () => {
|
||||
const configPath = "/path/to/config.json";
|
||||
var expectedDefaultConfig = getDefaultConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
expectedDefaultConfig = getDefaultConfig();
|
||||
|
||||
mockFsService.pathJoin.mockReturnValue(configPath);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("formats the config file path", () => {
|
||||
new ConfigService(mockFsService, mockOsService);
|
||||
|
||||
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, mockOsService);
|
||||
|
||||
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 = {
|
||||
codexRoot: "defined",
|
||||
isTestConfig: "Yes, very",
|
||||
};
|
||||
mockFsService.readJsonFile.mockReturnValue(savedConfig);
|
||||
|
||||
const service = new ConfigService(mockFsService, mockOsService);
|
||||
|
||||
expect(mockFsService.isFile).toHaveBeenCalledWith(configPath);
|
||||
expect(mockFsService.readJsonFile).toHaveBeenCalledWith(configPath);
|
||||
expect(mockFsService.writeJsonFile).not.toHaveBeenCalled();
|
||||
expect(service.config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
it("saves the default config when config.json exists but doesn't define the codexRoot", () => {
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
const savedConfig = {
|
||||
codexRoot: undefined, // it still blows my mind we have a language in which we can define things to be undefined.
|
||||
isTestConfig: "Yes, very",
|
||||
};
|
||||
mockFsService.readJsonFile.mockReturnValue(savedConfig);
|
||||
|
||||
const service = new ConfigService(mockFsService, mockOsService);
|
||||
|
||||
expect(mockFsService.isFile).toHaveBeenCalledWith(configPath);
|
||||
expect(mockFsService.readJsonFile).toHaveBeenCalledWith(configPath);
|
||||
expect(mockFsService.writeJsonFile).toHaveBeenCalled();
|
||||
expect(service.config).toEqual(expectedDefaultConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCodexExe", () => {
|
||||
var configService;
|
||||
const result = "path/to/codex";
|
||||
|
||||
beforeEach(() => {
|
||||
mockFsService.isFile.mockReturnValue(false);
|
||||
mockFsService.pathJoin.mockReturnValue(result);
|
||||
configService = new ConfigService(mockFsService, mockOsService);
|
||||
});
|
||||
|
||||
it("joins the codex root with the non-Windows specific exe name", () => {
|
||||
mockOsService.isWindows.mockReturnValue(false);
|
||||
|
||||
expect(configService.getCodexExe()).toBe(result);
|
||||
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
|
||||
expectedDefaultConfig.codexRoot,
|
||||
"codex",
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins the codex root with the Windows specific exe name", () => {
|
||||
mockOsService.isWindows.mockReturnValue(true);
|
||||
|
||||
expect(configService.getCodexExe()).toBe(result);
|
||||
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
|
||||
expectedDefaultConfig.codexRoot,
|
||||
"codex.exe",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCodexConfigFilePath", () => {
|
||||
const result = "path/to/codex";
|
||||
|
||||
it("joins the codex root and codexConfigFile", () => {
|
||||
mockFsService.pathJoin.mockReturnValue(result);
|
||||
const configService = new ConfigService(mockFsService, mockOsService);
|
||||
|
||||
expect(configService.getCodexConfigFilePath()).toBe(result);
|
||||
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
|
||||
expectedDefaultConfig.codexRoot,
|
||||
"config.toml",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEthFilePaths", () => {
|
||||
const result1 = "path/to/key";
|
||||
const result2 = "path/to/address";
|
||||
|
||||
it("returns the key and address file paths", () => {
|
||||
const configService = new ConfigService(mockFsService, mockOsService);
|
||||
|
||||
mockFsService.pathJoin = vi.fn();
|
||||
mockFsService.pathJoin.mockReturnValueOnce(result1);
|
||||
mockFsService.pathJoin.mockReturnValueOnce(result2);
|
||||
|
||||
expect(configService.getEthFilePaths()).toEqual({
|
||||
key: result1,
|
||||
address: result2,
|
||||
});
|
||||
|
||||
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
|
||||
expectedDefaultConfig.codexRoot,
|
||||
"eth.key",
|
||||
]);
|
||||
expect(mockFsService.pathJoin).toHaveBeenCalledWith([
|
||||
expectedDefaultConfig.codexRoot,
|
||||
"eth.address",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfiguration", () => {
|
||||
var configService;
|
||||
var config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = expectedDefaultConfig;
|
||||
config.codexExe = "codex.exe";
|
||||
|
||||
configService = new ConfigService(mockFsService, mockOsService);
|
||||
configService.config = config;
|
||||
});
|
||||
|
||||
it("throws when storageQuota is less than 100 MB", () => {
|
||||
config.storageQuota = 1024 * 1024 * 99;
|
||||
|
||||
expect(configService.validateConfiguration).toThrow(
|
||||
"Storage quota must be at least 100MB",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes validation for default config when codexExe is set", () => {
|
||||
expect(configService.validateConfiguration).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeCodexConfigFile", () => {
|
||||
const logsPath = "C:\\path\\codex.log";
|
||||
var configService;
|
||||
|
||||
beforeEach(() => {
|
||||
// use the default config:
|
||||
mockFsService.isFile.mockReturnValue(false);
|
||||
|
||||
configService = new ConfigService(mockFsService, mockOsService);
|
||||
configService.validateConfiguration = vi.fn();
|
||||
configService.getLogFilePath = vi.fn();
|
||||
configService.getLogFilePath.mockReturnValue(logsPath);
|
||||
});
|
||||
|
||||
it("writes the config file values to the config TOML file", () => {
|
||||
const publicIp = "1.2.3.4";
|
||||
const bootstrapNodes = ["boot111", "boot222", "boot333"];
|
||||
const expectedDataDir = "datadir";
|
||||
const expectedLogFile = "codex.log";
|
||||
const codexConfigFilePath = "/path/to/config.toml";
|
||||
|
||||
configService.getCodexConfigFilePath = vi.fn();
|
||||
configService.getCodexConfigFilePath.mockReturnValue(codexConfigFilePath);
|
||||
|
||||
configService.writeCodexConfigFile(publicIp, bootstrapNodes);
|
||||
|
||||
const newLine = "\n";
|
||||
|
||||
expect(mockFsService.writeFile).toHaveBeenCalledWith(
|
||||
codexConfigFilePath,
|
||||
`data-dir=\"${expectedDataDir}"${newLine}` +
|
||||
`log-level="DEBUG"${newLine}` +
|
||||
`log-file="${expectedLogFile}"${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
|
||||
.map((v) => {
|
||||
return '"' + v + '"';
|
||||
})
|
||||
.join(",")}]${newLine}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/services/dataService.js
Normal file
85
src/services/dataService.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { Codex } from "@codex-storage/sdk-js";
|
||||
import { NodeUploadStategy } from "@codex-storage/sdk-js/node";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export class DataService {
|
||||
constructor(configService) {
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
upload = async (filePath) => {
|
||||
const data = this.getCodexData();
|
||||
|
||||
// We can use mime util to determine the content type of the file. But Codex will reject some
|
||||
// mimetypes. So we set it to octet-stream always.
|
||||
const contentType = "application/octet-stream";
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
const fileData = fs.readFileSync(filePath);
|
||||
|
||||
const metadata = { filename: filename, mimetype: contentType };
|
||||
|
||||
const strategy = new NodeUploadStategy(fileData, metadata);
|
||||
const uploadResponse = data.upload(strategy);
|
||||
const res = await uploadResponse.result;
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.data);
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
download = async (cid) => {
|
||||
const data = this.getCodexData();
|
||||
const manifest = await data.fetchManifest(cid);
|
||||
const filename = this.getFilename(manifest);
|
||||
|
||||
const response = await data.networkDownloadStream(cid);
|
||||
const fileData = await response.data.text();
|
||||
|
||||
fs.writeFileSync(filename, fileData);
|
||||
return filename;
|
||||
};
|
||||
|
||||
debugInfo = async () => {
|
||||
const debug = this.getCodexDebug();
|
||||
const res = await debug.info();
|
||||
if (res.error) {
|
||||
throw new Error(res.data);
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
localData = async () => {
|
||||
const data = this.getCodexData();
|
||||
const res = await data.cids();
|
||||
if (res.error) {
|
||||
throw new Error(res.data);
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
getCodex = () => {
|
||||
const config = this.configService.get();
|
||||
const url = `http://localhost:${config.ports.apiPort}`;
|
||||
const codex = new Codex(url);
|
||||
return codex;
|
||||
};
|
||||
|
||||
getCodexData = () => {
|
||||
return this.getCodex().data;
|
||||
};
|
||||
|
||||
getCodexDebug = () => {
|
||||
return this.getCodex().debug;
|
||||
};
|
||||
|
||||
getFilename = (manifest) => {
|
||||
const defaultFilename = "unknown_" + Math.random();
|
||||
const filename = manifest?.data?.manifest?.filename;
|
||||
|
||||
if (filename == undefined || filename.length < 1) return defaultFilename;
|
||||
return filename;
|
||||
};
|
||||
}
|
||||
53
src/services/ethersService.js
Normal file
53
src/services/ethersService.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { ethers } from "ethers";
|
||||
import crypto from "crypto";
|
||||
|
||||
export class EthersService {
|
||||
constructor(fsService, configService, osService, shellService) {
|
||||
this.fs = fsService;
|
||||
this.configService = configService;
|
||||
this.os = osService;
|
||||
this.shell = shellService;
|
||||
}
|
||||
|
||||
getOrCreateEthKey = () => {
|
||||
const paths = this.configService.getEthFilePaths();
|
||||
|
||||
if (!this.fs.isFile(paths.key)) {
|
||||
this.generateAndSaveKey(paths);
|
||||
}
|
||||
|
||||
const address = this.fs.readFile(paths.address);
|
||||
|
||||
return {
|
||||
privateKeyFilePath: paths.key,
|
||||
addressFilePath: paths.address,
|
||||
address: address,
|
||||
};
|
||||
};
|
||||
|
||||
generateAndSaveKey = async (paths) => {
|
||||
const keys = this.generateKey();
|
||||
this.fs.writeFile(paths.key, keys.key);
|
||||
this.fs.writeFile(paths.address, keys.address);
|
||||
|
||||
if (this.os.isWindows()) {
|
||||
const username = this.os.getUsername();
|
||||
this.shell.run(`icacls ${paths.key} /inheritance:r >nul 2>&1`);
|
||||
this.shell.run(`icacls ${paths.key} /grant:r ${username}:F >nul 2>&1`);
|
||||
this.shell.run(`icacls ${paths.key} /remove SYSTEM >nul 2>&1`);
|
||||
this.shell.run(`icacls ${paths.key} /remove Administrators >nul 2>&1`);
|
||||
} else {
|
||||
this.shell.run(`chmod 600 "${paths.key}"`);
|
||||
}
|
||||
};
|
||||
|
||||
generateKey = () => {
|
||||
var id = crypto.randomBytes(32).toString("hex");
|
||||
var privateKey = "0x" + id;
|
||||
var wallet = new ethers.Wallet(privateKey);
|
||||
return {
|
||||
key: privateKey,
|
||||
address: wallet.address,
|
||||
};
|
||||
};
|
||||
}
|
||||
89
src/services/fsService.js
Normal file
89
src/services/fsService.js
Normal file
@ -0,0 +1,89 @@
|
||||
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) {
|
||||
const mount = volume.mountPoint;
|
||||
if (mount != null && mount != undefined && mount.length > 0) {
|
||||
try {
|
||||
if (!fs.lstatSync(mount).isFile()) {
|
||||
mountPoints.push(mount);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (mountPoints.length < 1) {
|
||||
// In certain containerized environments, the devices don't reveal any
|
||||
// useful mounts. We'll proceed under the assumption that '/' is valid here.
|
||||
return ["/"];
|
||||
}
|
||||
return mountPoints;
|
||||
};
|
||||
|
||||
pathJoin = (parts) => {
|
||||
return path.join(...parts);
|
||||
};
|
||||
|
||||
isDir = (dir) => {
|
||||
try {
|
||||
return fs.lstatSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
isFile = (path) => {
|
||||
try {
|
||||
return fs.lstatSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
readDir = (dir) => {
|
||||
return fs.readdirSync(dir);
|
||||
};
|
||||
|
||||
makeDir = (dir) => {
|
||||
fs.mkdirSync(dir);
|
||||
};
|
||||
|
||||
moveDir = (oldPath, newPath) => {
|
||||
fs.moveSync(oldPath, newPath);
|
||||
};
|
||||
|
||||
deleteDir = (dir) => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
readJsonFile = (filePath) => {
|
||||
return JSON.parse(fs.readFileSync(filePath));
|
||||
};
|
||||
|
||||
readFile = (filePath) => {
|
||||
return fs.readFileSync(filePath);
|
||||
};
|
||||
|
||||
writeJsonFile = (filePath, jsonObject) => {
|
||||
fs.writeFileSync(filePath, JSON.stringify(jsonObject));
|
||||
};
|
||||
|
||||
writeFile = (filePath, content) => {
|
||||
fs.writeFileSync(filePath, content);
|
||||
};
|
||||
|
||||
ensureDirExists = (dir) => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { runCommand } from '../utils/command.js';
|
||||
import { showErrorMessage, showInfoMessage, showSuccessMessage } from '../utils/messages.js';
|
||||
import os from 'os';
|
||||
import { getCodexVersion } from '../handlers/installationHandlers.js';
|
||||
import axios from "axios";
|
||||
import { runCommand } from "../utils/command.js";
|
||||
import {
|
||||
showErrorMessage,
|
||||
showInfoMessage,
|
||||
showSuccessMessage,
|
||||
} from "../utils/messages.js";
|
||||
import os from "os";
|
||||
import { getCodexVersion } from "../handlers/installationHandlers.js";
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
@ -10,145 +14,175 @@ const platform = os.platform();
|
||||
let currentWallet = null;
|
||||
|
||||
export async function setWalletAddress(wallet) {
|
||||
// Basic ERC20 address validation
|
||||
if (wallet && !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
|
||||
throw new Error('Invalid ERC20 wallet address format');
|
||||
}
|
||||
currentWallet = wallet;
|
||||
// Basic ERC20 address validation
|
||||
if (wallet && !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
|
||||
throw new Error("Invalid ERC20 wallet address format");
|
||||
}
|
||||
currentWallet = wallet;
|
||||
}
|
||||
|
||||
export async function getWalletAddress() {
|
||||
return currentWallet;
|
||||
return currentWallet;
|
||||
}
|
||||
|
||||
export async function isNodeRunning(config) {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:${config.ports.apiPort}/api/codex/v1/debug/info`);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`http://localhost:${config.ports.apiPort}/api/codex/v1/debug/info`,
|
||||
);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCodexInstalled(config) {
|
||||
try {
|
||||
const version = await getCodexVersion(config);
|
||||
return version.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const version = await getCodexVersion(config);
|
||||
return version.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logToSupabase(nodeData, retryCount = 3, retryDelay = 1000) {
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
export async function logToSupabase(
|
||||
nodeData,
|
||||
retryCount = 3,
|
||||
retryDelay = 1000,
|
||||
) {
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
for (let attempt = 1; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
const peerCount = nodeData.table.nodes ? nodeData.table.nodes.length : "0";
|
||||
const payload = {
|
||||
nodeId: nodeData.table.localNode.nodeId,
|
||||
peerId: nodeData.table.localNode.peerId,
|
||||
publicIp: nodeData.announceAddresses[0].split('/')[2],
|
||||
version: nodeData.codex.version,
|
||||
peerCount: peerCount == 0 ? "0" : peerCount,
|
||||
port: nodeData.announceAddresses[0].split('/')[4],
|
||||
listeningAddress: nodeData.table.localNode.address,
|
||||
timestamp: new Date().toISOString(),
|
||||
wallet: currentWallet
|
||||
};
|
||||
for (let attempt = 1; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
const peerCount = nodeData.table.nodes
|
||||
? nodeData.table.nodes.length
|
||||
: "0";
|
||||
const payload = {
|
||||
nodeId: nodeData.table.localNode.nodeId,
|
||||
peerId: nodeData.table.localNode.peerId,
|
||||
publicIp: nodeData.announceAddresses[0].split("/")[2],
|
||||
version: nodeData.codex.version,
|
||||
peerCount: peerCount == 0 ? "0" : peerCount,
|
||||
port: nodeData.announceAddresses[0].split("/")[4],
|
||||
listeningAddress: nodeData.table.localNode.address,
|
||||
timestamp: new Date().toISOString(),
|
||||
wallet: currentWallet,
|
||||
};
|
||||
|
||||
const response = await axios.post('https://vfcnsjxahocmzefhckfz.supabase.co/functions/v1/codexnodes', payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
const isLastAttempt = attempt === retryCount;
|
||||
const isNetworkError = error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT' || error.code === 'ECONNREFUSED';
|
||||
const response = await axios.post(
|
||||
"https://vfcnsjxahocmzefhckfz.supabase.co/functions/v1/codexnodes",
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLastAttempt || !isNetworkError) {
|
||||
console.error(`Failed to log node data (attempt ${attempt}/${retryCount}):`, error.message);
|
||||
if (error.response) {
|
||||
console.error('Error response:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data
|
||||
});
|
||||
}
|
||||
if (isLastAttempt) return false;
|
||||
} else {
|
||||
// Only log retry attempts for network errors
|
||||
console.log(`Retrying to log data (attempt ${attempt}/${retryCount})...`);
|
||||
await delay(retryDelay);
|
||||
}
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
const isLastAttempt = attempt === retryCount;
|
||||
const isNetworkError =
|
||||
error.code === "ENOTFOUND" ||
|
||||
error.code === "ETIMEDOUT" ||
|
||||
error.code === "ECONNREFUSED";
|
||||
|
||||
if (isLastAttempt || !isNetworkError) {
|
||||
console.error(
|
||||
`Failed to log node data (attempt ${attempt}/${retryCount}):`,
|
||||
error.message,
|
||||
);
|
||||
if (error.response) {
|
||||
console.error("Error response:", {
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
});
|
||||
}
|
||||
if (isLastAttempt) return false;
|
||||
} else {
|
||||
// Only log retry attempts for network errors
|
||||
console.log(
|
||||
`Retrying to log data (attempt ${attempt}/${retryCount})...`,
|
||||
);
|
||||
await delay(retryDelay);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkDependencies() {
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
await runCommand('ldconfig -p | grep libgomp');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(showErrorMessage('Required dependency libgomp1 is not installed.'));
|
||||
console.log(showInfoMessage(
|
||||
'For Debian-based Linux systems, please install it manually using:\n\n' +
|
||||
'sudo apt update && sudo apt install libgomp1'
|
||||
));
|
||||
return false;
|
||||
}
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
await runCommand("ldconfig -p | grep libgomp");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
showErrorMessage("Required dependency libgomp1 is not installed."),
|
||||
);
|
||||
console.log(
|
||||
showInfoMessage(
|
||||
"For Debian-based Linux systems, please install it manually using:\n\n" +
|
||||
"sudo apt update && sudo apt install libgomp1",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function startPeriodicLogging(config) {
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000; // 15 minutes in milliseconds
|
||||
|
||||
const logNodeInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:${config.ports.apiPort}/api/codex/v1/debug/info`);
|
||||
if (response.status === 200) {
|
||||
await logToSupabase(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle any logging errors to not disrupt the node operation
|
||||
console.error('Failed to log node data:', error.message);
|
||||
}
|
||||
};
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000; // 15 minutes in milliseconds
|
||||
|
||||
// Initial log
|
||||
await logNodeInfo();
|
||||
const logNodeInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`http://localhost:${config.ports.apiPort}/api/codex/v1/debug/info`,
|
||||
);
|
||||
if (response.status === 200) {
|
||||
await logToSupabase(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle any logging errors to not disrupt the node operation
|
||||
console.error("Failed to log node data:", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up periodic logging
|
||||
const intervalId = setInterval(logNodeInfo, FIFTEEN_MINUTES);
|
||||
// Initial log
|
||||
await logNodeInfo();
|
||||
|
||||
// Return cleanup function
|
||||
return () => clearInterval(intervalId);
|
||||
// Set up periodic logging
|
||||
const intervalId = setInterval(logNodeInfo, FIFTEEN_MINUTES);
|
||||
|
||||
// Return cleanup function
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
|
||||
export async function updateWalletAddress(nodeId, wallet) {
|
||||
// Basic ERC20 address validation
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
|
||||
throw new Error('Invalid ERC20 wallet address format');
|
||||
}
|
||||
// Basic ERC20 address validation
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
|
||||
throw new Error("Invalid ERC20 wallet address format");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('https://vfcnsjxahocmzefhckfz.supabase.co/functions/v1/wallet', {
|
||||
nodeId,
|
||||
wallet
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error('Failed to update wallet address:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"https://vfcnsjxahocmzefhckfz.supabase.co/functions/v1/wallet",
|
||||
{
|
||||
nodeId,
|
||||
wallet,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error("Failed to update wallet address:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
40
src/services/osService.js
Normal file
40
src/services/osService.js
Normal file
@ -0,0 +1,40 @@
|
||||
import os from "os";
|
||||
import psList from "ps-list";
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
listProcesses = async () => {
|
||||
return await psList();
|
||||
};
|
||||
|
||||
stopProcess = (pid) => {
|
||||
process.kill(pid, "SIGINT");
|
||||
};
|
||||
|
||||
terminateProcess = (pid) => {
|
||||
process.kill(pid, "SIGTERM");
|
||||
};
|
||||
|
||||
getUsername = () => {
|
||||
return os.userInfo().username;
|
||||
};
|
||||
}
|
||||
42
src/services/shellService.js
Normal file
42
src/services/shellService.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { exec, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export class ShellService {
|
||||
constructor() {
|
||||
this.execAsync = promisify(exec);
|
||||
}
|
||||
|
||||
run = async (command) => {
|
||||
try {
|
||||
const { stdout, stderr } = await this.execAsync(command);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error:", error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
spawnDetachedProcess = async (cmd, workingDir, args) => {
|
||||
var child = spawn(cmd, args, {
|
||||
cwd: workingDir,
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
// child.stdout.on("data", (data) => {
|
||||
// console.log(`stdout: ${data}`);
|
||||
// });
|
||||
|
||||
// child.stderr.on("data", (data) => {
|
||||
// console.error(`stderr: ${data}`);
|
||||
// });
|
||||
|
||||
// child.on("close", (code) => {
|
||||
// console.log(`child process exited with code ${code}`);
|
||||
// });
|
||||
|
||||
child.unref();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
};
|
||||
}
|
||||
105
src/services/uiService.js
Normal file
105
src/services/uiService.js
Normal file
@ -0,0 +1,105 @@
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import inquirer from "inquirer";
|
||||
import { createSpinner } from "nanospinner";
|
||||
|
||||
import { ASCII_ART } from "../constants/ascii.js";
|
||||
|
||||
function show(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
export class UiService {
|
||||
showSuccessMessage = (message) => {
|
||||
show(
|
||||
boxen(chalk.green(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "green",
|
||||
title: "✅ SUCCESS",
|
||||
titleAlignment: "center",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
showErrorMessage = (message) => {
|
||||
show(
|
||||
boxen(chalk.red(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "red",
|
||||
title: "❌ ERROR",
|
||||
titleAlignment: "center",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
showInfoMessage = (message) => {
|
||||
show(
|
||||
boxen(chalk.cyan(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "cyan",
|
||||
title: "ℹ️ INFO",
|
||||
titleAlignment: "center",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
showLogo = () => {
|
||||
console.log("\n" + chalk.cyanBright(ASCII_ART));
|
||||
};
|
||||
|
||||
askMultipleChoice = async (message, choices) => {
|
||||
var counter = 1;
|
||||
var promptChoices = [];
|
||||
choices.forEach(function (choice) {
|
||||
promptChoices.push(`${counter}. ${choice.label}`);
|
||||
counter++;
|
||||
});
|
||||
|
||||
const { choice } = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "choice",
|
||||
message: message,
|
||||
choices: promptChoices,
|
||||
pageSize: counter - 1,
|
||||
loop: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectStr = choice.split(".")[0];
|
||||
const selectIndex = parseInt(selectStr) - 1;
|
||||
|
||||
await choices[selectIndex].action();
|
||||
};
|
||||
|
||||
askPrompt = async (prompt) => {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "valueStr",
|
||||
message: prompt,
|
||||
},
|
||||
]);
|
||||
return response.valueStr;
|
||||
};
|
||||
|
||||
createAndStartSpinner = (message) => {
|
||||
return createSpinner(message).start();
|
||||
};
|
||||
|
||||
stopSpinnerSuccess = (spinner) => {
|
||||
if (spinner == undefined) return;
|
||||
spinner.stop();
|
||||
};
|
||||
|
||||
stopSpinnerError = (spinner) => {
|
||||
if (spinner == undefined) return;
|
||||
spinner.error();
|
||||
};
|
||||
}
|
||||
129
src/ui/configMenu.js
Normal file
129
src/ui/configMenu.js
Normal file
@ -0,0 +1,129 @@
|
||||
export class ConfigMenu {
|
||||
constructor(uiService, menuLoop, configService, numberSelector) {
|
||||
this.ui = uiService;
|
||||
this.loop = menuLoop;
|
||||
this.configService = configService;
|
||||
this.numberSelector = numberSelector;
|
||||
|
||||
this.loop.initialize(this.showConfigMenu);
|
||||
}
|
||||
|
||||
show = async () => {
|
||||
this.config = this.configService.get();
|
||||
this.ui.showInfoMessage("Codex Configuration");
|
||||
await this.loop.showLoop();
|
||||
};
|
||||
|
||||
showConfigMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Select to edit:", [
|
||||
{
|
||||
label: `Storage quota = ${this.bytesAmountToString(this.config.storageQuota)}`,
|
||||
action: this.editStorageQuota,
|
||||
},
|
||||
{
|
||||
label: `Discovery port = ${this.config.ports.discPort}`,
|
||||
action: this.editDiscPort,
|
||||
},
|
||||
{
|
||||
label: `P2P listen port = ${this.config.ports.listenPort}`,
|
||||
action: this.editListenPort,
|
||||
},
|
||||
{
|
||||
label: `API port = ${this.config.ports.apiPort}`,
|
||||
action: this.editApiPort,
|
||||
},
|
||||
{
|
||||
label: "Save changes and exit",
|
||||
action: this.saveChangesAndExit,
|
||||
},
|
||||
{
|
||||
label: "Discard changes and exit",
|
||||
action: this.discardChangesAndExit,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// this and the byte-format handling in
|
||||
// numberSelector should be extracted to
|
||||
// their own util.
|
||||
bytesAmountToString = (numBytes) => {
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
var value = numBytes;
|
||||
var index = 0;
|
||||
while (value > 1024) {
|
||||
index = index + 1;
|
||||
value = value / 1024;
|
||||
}
|
||||
|
||||
if (index == 0) return `${numBytes} Bytes`;
|
||||
return `${numBytes} Bytes (${value} ${units[index]})`;
|
||||
};
|
||||
|
||||
editStorageQuota = async () => {
|
||||
this.ui.showInfoMessage("You can use: 'GB' or 'gb', etc.");
|
||||
const newQuota = await this.numberSelector.show(
|
||||
this.config.storageQuota,
|
||||
"Storage quota",
|
||||
true,
|
||||
);
|
||||
if (newQuota < 100 * 1024 * 1024) {
|
||||
this.ui.showErrorMessage("Storage quote should be >= 100mb.");
|
||||
} else {
|
||||
this.config.storageQuota = newQuota;
|
||||
}
|
||||
};
|
||||
|
||||
editDiscPort = async () => {
|
||||
const newPort = await this.numberSelector.show(
|
||||
this.config.ports.discPort,
|
||||
"Discovery port",
|
||||
false,
|
||||
);
|
||||
if (this.isInPortRange(newPort)) {
|
||||
this.config.ports.discPort = newPort;
|
||||
}
|
||||
};
|
||||
|
||||
editListenPort = async () => {
|
||||
const newPort = await this.numberSelector.show(
|
||||
this.config.ports.listenPort,
|
||||
"P2P listen port",
|
||||
false,
|
||||
);
|
||||
if (this.isInPortRange(newPort)) {
|
||||
this.config.ports.listenPort = newPort;
|
||||
}
|
||||
};
|
||||
|
||||
editApiPort = async () => {
|
||||
const newPort = await this.numberSelector.show(
|
||||
this.config.ports.apiPort,
|
||||
"API port",
|
||||
false,
|
||||
);
|
||||
if (this.isInPortRange(newPort)) {
|
||||
this.config.ports.apiPort = newPort;
|
||||
}
|
||||
};
|
||||
|
||||
isInPortRange = (number) => {
|
||||
if (number < 1024 || number > 65535) {
|
||||
this.ui.showErrorMessage("Port should be between 1024 and 65535.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
saveChangesAndExit = async () => {
|
||||
this.configService.saveConfig();
|
||||
this.ui.showInfoMessage("Configuration changes saved.");
|
||||
this.loop.stopLoop();
|
||||
};
|
||||
|
||||
discardChangesAndExit = async () => {
|
||||
this.configService.loadConfig();
|
||||
this.ui.showInfoMessage("Changes discarded.");
|
||||
this.loop.stopLoop();
|
||||
};
|
||||
}
|
||||
224
src/ui/configMenu.test.js
Normal file
224
src/ui/configMenu.test.js
Normal file
@ -0,0 +1,224 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { ConfigMenu } from "./configMenu.js";
|
||||
import { mockUiService } from "../__mocks__/service.mocks.js";
|
||||
import { mockConfigService } from "../__mocks__/service.mocks.js";
|
||||
import { mockNumberSelector, mockMenuLoop } from "../__mocks__/utils.mocks.js";
|
||||
|
||||
describe("ConfigMenu", () => {
|
||||
const config = {
|
||||
dataDir: "/data",
|
||||
logsDir: "/logs",
|
||||
storageQuota: 1024 * 1024 * 1024,
|
||||
ports: {
|
||||
discPort: 8090,
|
||||
listenPort: 8070,
|
||||
apiPort: 8080,
|
||||
},
|
||||
};
|
||||
let configMenu;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockConfigService.get.mockReturnValue(config);
|
||||
|
||||
configMenu = new ConfigMenu(
|
||||
mockUiService,
|
||||
mockMenuLoop,
|
||||
mockConfigService,
|
||||
mockNumberSelector,
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes the loop with the config menu", () => {
|
||||
expect(mockMenuLoop.initialize).toHaveBeenCalledWith(
|
||||
configMenu.showConfigMenu,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the config menu header", async () => {
|
||||
await configMenu.show();
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Codex Configuration",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the menu loop", async () => {
|
||||
await configMenu.show();
|
||||
expect(mockMenuLoop.showLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the config field", async () => {
|
||||
await configMenu.show();
|
||||
expect(configMenu.config).toEqual(config);
|
||||
});
|
||||
|
||||
describe("config menu options", () => {
|
||||
beforeEach(() => {
|
||||
configMenu.config = config;
|
||||
});
|
||||
|
||||
it("displays the configuration menu", async () => {
|
||||
await configMenu.showConfigMenu();
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Select to edit:",
|
||||
[
|
||||
{
|
||||
label: `Storage quota = 1073741824 Bytes (1024 MB)`,
|
||||
action: configMenu.editStorageQuota,
|
||||
},
|
||||
{
|
||||
label: `Discovery port = ${mockConfigService.get().ports.discPort}`,
|
||||
action: configMenu.editDiscPort,
|
||||
},
|
||||
{
|
||||
label: `P2P listen port = ${mockConfigService.get().ports.listenPort}`,
|
||||
action: configMenu.editListenPort,
|
||||
},
|
||||
{
|
||||
label: `API port = ${mockConfigService.get().ports.apiPort}`,
|
||||
action: configMenu.editApiPort,
|
||||
},
|
||||
{
|
||||
label: "Save changes and exit",
|
||||
action: configMenu.saveChangesAndExit,
|
||||
},
|
||||
{
|
||||
label: "Discard changes and exit",
|
||||
action: configMenu.discardChangesAndExit,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("edits the storage quota", async () => {
|
||||
const originalQuota = config.storageQuota;
|
||||
const newQuota = 200 * 1024 * 1024;
|
||||
mockNumberSelector.show.mockResolvedValue(newQuota);
|
||||
|
||||
await configMenu.editStorageQuota();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"You can use: 'GB' or 'gb', etc.",
|
||||
);
|
||||
expect(mockNumberSelector.show).toHaveBeenCalledWith(
|
||||
originalQuota,
|
||||
"Storage quota",
|
||||
true,
|
||||
);
|
||||
expect(configMenu.config.storageQuota).toEqual(newQuota);
|
||||
});
|
||||
|
||||
it("shows an error if storage quota is too small", async () => {
|
||||
const originalQuota = config.storageQuota;
|
||||
mockNumberSelector.show.mockResolvedValue(50 * 1024 * 1024);
|
||||
|
||||
await configMenu.editStorageQuota();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Storage quote should be >= 100mb.",
|
||||
);
|
||||
expect(configMenu.config.storageQuota).toEqual(originalQuota);
|
||||
});
|
||||
|
||||
it("edits the discovery port", async () => {
|
||||
const originalPort = config.ports.discPort;
|
||||
const newPort = 9000;
|
||||
mockNumberSelector.show.mockResolvedValue(newPort);
|
||||
|
||||
await configMenu.editDiscPort();
|
||||
|
||||
expect(mockNumberSelector.show).toHaveBeenCalledWith(
|
||||
originalPort,
|
||||
"Discovery port",
|
||||
false,
|
||||
);
|
||||
expect(configMenu.config.ports.discPort).toEqual(newPort);
|
||||
});
|
||||
|
||||
it("shows an error if discovery port is out of range", async () => {
|
||||
const originalPort = config.ports.discPort;
|
||||
mockNumberSelector.show.mockResolvedValue(1000);
|
||||
await configMenu.editDiscPort();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Port should be between 1024 and 65535.",
|
||||
);
|
||||
expect(configMenu.config.ports.discPort).toEqual(originalPort);
|
||||
});
|
||||
|
||||
it("edits the listen port", async () => {
|
||||
const originalPort = config.ports.listenPort;
|
||||
const newPort = 9000;
|
||||
mockNumberSelector.show.mockResolvedValue(newPort);
|
||||
|
||||
await configMenu.editListenPort();
|
||||
|
||||
expect(mockNumberSelector.show).toHaveBeenCalledWith(
|
||||
originalPort,
|
||||
"P2P listen port",
|
||||
false,
|
||||
);
|
||||
expect(configMenu.config.ports.listenPort).toEqual(newPort);
|
||||
});
|
||||
|
||||
it("shows an error if listen port is out of range", async () => {
|
||||
const originalPort = config.ports.listenPort;
|
||||
mockNumberSelector.show.mockResolvedValue(1000);
|
||||
await configMenu.editListenPort();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Port should be between 1024 and 65535.",
|
||||
);
|
||||
expect(configMenu.config.ports.listenPort).toEqual(originalPort);
|
||||
});
|
||||
|
||||
it("edits the API port", async () => {
|
||||
const originalPort = config.ports.apiPort;
|
||||
const newPort = 9000;
|
||||
mockNumberSelector.show.mockResolvedValue(newPort);
|
||||
|
||||
await configMenu.editApiPort();
|
||||
|
||||
expect(mockNumberSelector.show).toHaveBeenCalledWith(
|
||||
originalPort,
|
||||
"API port",
|
||||
false,
|
||||
);
|
||||
expect(configMenu.config.ports.apiPort).toEqual(newPort);
|
||||
});
|
||||
|
||||
it("shows an error if API port is out of range", async () => {
|
||||
const originalPort = config.ports.apiPort;
|
||||
mockNumberSelector.show.mockResolvedValue(1000);
|
||||
await configMenu.editApiPort();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Port should be between 1024 and 65535.",
|
||||
);
|
||||
expect(configMenu.config.ports.apiPort).toEqual(originalPort);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save and discard changes", () => {
|
||||
it("saves changes and exits", async () => {
|
||||
await configMenu.show();
|
||||
await configMenu.saveChangesAndExit();
|
||||
|
||||
expect(mockConfigService.saveConfig).toHaveBeenCalled();
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Configuration changes saved.",
|
||||
);
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards changes and exits", async () => {
|
||||
await configMenu.discardChangesAndExit();
|
||||
|
||||
expect(mockConfigService.loadConfig).toHaveBeenCalled();
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Changes discarded.",
|
||||
);
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/ui/dataMenu.js
Normal file
76
src/ui/dataMenu.js
Normal file
@ -0,0 +1,76 @@
|
||||
export class DataMenu {
|
||||
constructor(uiService, fsService, dataService) {
|
||||
this.ui = uiService;
|
||||
this.fs = fsService;
|
||||
this.dataService = dataService;
|
||||
}
|
||||
|
||||
performUpload = async () => {
|
||||
this.ui.showInfoMessage(
|
||||
"⚠️ Codex does not encrypt files. Anything uploaded will be available publicly on testnet.",
|
||||
);
|
||||
|
||||
const filePath = await this.ui.askPrompt("Enter the file path");
|
||||
if (!this.fs.isFile(filePath)) {
|
||||
this.ui.showErrorMessage("File not found");
|
||||
} else {
|
||||
try {
|
||||
const cid = await this.dataService.upload(filePath);
|
||||
this.ui.showInfoMessage(`Upload successful.\n CID: '${cid}'`);
|
||||
} catch (exception) {
|
||||
this.ui.showErrorMessage("Error during upload: " + exception);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
performDownload = async () => {
|
||||
const cid = await this.ui.askPrompt("Enter the CID");
|
||||
if (cid.length < 1) return;
|
||||
try {
|
||||
const filename = await this.dataService.download(cid);
|
||||
this.ui.showInfoMessage(`Download successful.\n File: '${filename}'`);
|
||||
} catch (exception) {
|
||||
this.ui.showErrorMessage("Error during download: " + exception);
|
||||
}
|
||||
};
|
||||
|
||||
showLocalData = async () => {
|
||||
try {
|
||||
const localData = await this.dataService.localData();
|
||||
this.displayLocalData(localData);
|
||||
} catch (exception) {
|
||||
this.ui.showErrorMessage("Failed to fetch local data: " + exception);
|
||||
}
|
||||
};
|
||||
|
||||
displayLocalData = (filesData) => {
|
||||
if (filesData.content && filesData.content.length > 0) {
|
||||
this.ui.showInfoMessage(
|
||||
`Found ${filesData.content.length} local file(s)`,
|
||||
);
|
||||
|
||||
filesData.content.forEach((file, index) => {
|
||||
const { cid, manifest } = file;
|
||||
const {
|
||||
datasetSize,
|
||||
protected: isProtected,
|
||||
filename,
|
||||
mimetype,
|
||||
} = manifest;
|
||||
|
||||
const fileSize = (datasetSize / 1024).toFixed(2);
|
||||
|
||||
this.ui.showInfoMessage(
|
||||
`File ${index + 1} of ${filesData.content.length}\n\n` +
|
||||
`Filename: ${filename}\n` +
|
||||
`CID: ${cid}\n` +
|
||||
`Size: ${fileSize} KB\n` +
|
||||
`MIME Type: ${mimetype}\n` +
|
||||
`Protected: ${isProtected ? "Yes" : "No"}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.ui.showInfoMessage("Node contains no datasets.");
|
||||
}
|
||||
};
|
||||
}
|
||||
214
src/ui/dataMenu.test.js
Normal file
214
src/ui/dataMenu.test.js
Normal file
@ -0,0 +1,214 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { DataMenu } from "./dataMenu.js";
|
||||
import {
|
||||
mockUiService,
|
||||
mockFsService,
|
||||
mockDataService,
|
||||
} from "../__mocks__/service.mocks.js";
|
||||
|
||||
describe("DataMenu", () => {
|
||||
let dataMenu;
|
||||
const filePath = "testfilepath";
|
||||
const cid = "testcid";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
dataMenu = new DataMenu(mockUiService, mockFsService, mockDataService);
|
||||
});
|
||||
|
||||
describe("performUpload", () => {
|
||||
beforeEach(() => {
|
||||
mockUiService.askPrompt.mockResolvedValue(filePath);
|
||||
mockDataService.upload.mockResolvedValue(cid);
|
||||
});
|
||||
|
||||
it("shows encryption warning", async () => {
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"⚠️ Codex does not encrypt files. Anything uploaded will be available publicly on testnet.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prompts the user for a filepath", async () => {
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockUiService.askPrompt).toHaveBeenCalledWith(
|
||||
"Enter the file path",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks that the provided path is a file", async () => {
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockFsService.isFile).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it("shows an error when the provided path is not a file", async () => {
|
||||
mockFsService.isFile.mockReturnValue(false);
|
||||
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"File not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls the data service if the file does exist", async () => {
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockDataService.upload).toHaveBeenCalledWith(filePath);
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
`Upload successful.\n CID: '${cid}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error message when dataService throws", async () => {
|
||||
const error = "testError";
|
||||
mockFsService.isFile.mockReturnValue(true);
|
||||
mockDataService.upload.mockRejectedValueOnce(new Error(error));
|
||||
|
||||
await dataMenu.performUpload();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Error during upload: Error: " + error,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performDownload", () => {
|
||||
beforeEach(() => {
|
||||
mockUiService.askPrompt.mockResolvedValue(cid);
|
||||
mockDataService.download.mockResolvedValue(filePath);
|
||||
});
|
||||
|
||||
it("prompts the user for a cid", async () => {
|
||||
await dataMenu.performDownload();
|
||||
|
||||
expect(mockUiService.askPrompt).toHaveBeenCalledWith("Enter the CID");
|
||||
});
|
||||
|
||||
it("does nothing if provided input is empty", async () => {
|
||||
mockUiService.askPrompt = vi.fn();
|
||||
mockUiService.askPrompt.mockResolvedValue("");
|
||||
|
||||
await dataMenu.performDownload();
|
||||
|
||||
expect(mockDataService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the data service with the provided cid", async () => {
|
||||
await dataMenu.performDownload();
|
||||
|
||||
expect(mockDataService.download).toHaveBeenCalledWith(cid);
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
`Download successful.\n File: '${filePath}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error message when dataService throws", async () => {
|
||||
const error = "testError";
|
||||
mockDataService.download.mockRejectedValueOnce(new Error(error));
|
||||
|
||||
await dataMenu.performDownload();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Error during download: Error: " + error,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showLocalData", () => {
|
||||
beforeEach(() => {
|
||||
dataMenu.displayLocalData = vi.fn();
|
||||
});
|
||||
|
||||
it("calls localData on dataService", async () => {
|
||||
await dataMenu.showLocalData();
|
||||
|
||||
expect(mockDataService.localData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes localData to displayLocalData", async () => {
|
||||
const someData = "yes";
|
||||
|
||||
mockDataService.localData.mockResolvedValue(someData);
|
||||
|
||||
await dataMenu.showLocalData();
|
||||
|
||||
expect(dataMenu.displayLocalData).toHaveBeenCalledWith(someData);
|
||||
});
|
||||
|
||||
it("shows an error message when localData raises", async () => {
|
||||
const error = "Omg error!";
|
||||
mockDataService.localData.mockRejectedValue(new Error(error));
|
||||
|
||||
await dataMenu.showLocalData();
|
||||
|
||||
expect(dataMenu.displayLocalData).not.toHaveBeenCalled();
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Failed to fetch local data: Error: " + error,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayLocalData", () => {
|
||||
const cid = "testCid";
|
||||
const datasetSize = 2048;
|
||||
var isProtected = true;
|
||||
const filename = "filename.test";
|
||||
const mimetype = "test";
|
||||
var fileData = {};
|
||||
|
||||
beforeEach(() => {
|
||||
fileData = {
|
||||
content: [
|
||||
{
|
||||
cid: cid,
|
||||
manifest: {
|
||||
datasetSize,
|
||||
protected: isProtected,
|
||||
filename,
|
||||
mimetype,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it("shows no datasets when content is undefined", () => {
|
||||
fileData.content = undefined;
|
||||
|
||||
dataMenu.displayLocalData(fileData);
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Node contains no datasets.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no datasets when content is empty array", () => {
|
||||
fileData.content = [];
|
||||
dataMenu.displayLocalData(fileData);
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Node contains no datasets.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows details for each entry (protected)", () => {
|
||||
dataMenu.displayLocalData(fileData);
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
`File 1 of 1\n\n` +
|
||||
`Filename: ${filename}\n` +
|
||||
`CID: ${cid}\n` +
|
||||
`Size: ${(datasetSize / 1024).toFixed(2)} KB\n` +
|
||||
`MIME Type: ${mimetype}\n` +
|
||||
`Protected: Yes`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/ui/installMenu.js
Normal file
128
src/ui/installMenu.js
Normal file
@ -0,0 +1,128 @@
|
||||
export class InstallMenu {
|
||||
constructor(uiService, menuLoop, configService, pathSelector, installer) {
|
||||
this.ui = uiService;
|
||||
this.loop = menuLoop;
|
||||
this.configService = configService;
|
||||
this.config = configService.get();
|
||||
this.pathSelector = pathSelector;
|
||||
this.installer = installer;
|
||||
|
||||
this.loop.initialize(this.showMenu);
|
||||
}
|
||||
|
||||
show = async () => {
|
||||
await this.loop.showLoop();
|
||||
};
|
||||
|
||||
showMenu = async () => {
|
||||
if (await this.installer.isCodexInstalled()) {
|
||||
await this.showUninstallMenu();
|
||||
} else {
|
||||
await this.showInstallMenu();
|
||||
}
|
||||
};
|
||||
|
||||
showInstallMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Configure your Codex installation", [
|
||||
{
|
||||
label: "Install path: " + this.config.codexRoot,
|
||||
action: this.selectInstallPath,
|
||||
},
|
||||
{
|
||||
label: "Storage provider module: Disabled (todo)",
|
||||
action: this.storageProviderOption,
|
||||
},
|
||||
{
|
||||
label: "Install!",
|
||||
action: this.performInstall,
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
action: this.doNothing,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
showUninstallMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Codex is installed", [
|
||||
{
|
||||
label: "Uninstall",
|
||||
action: this.showConfirmUninstall,
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
action: this.doNothing,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
showConfirmUninstall = async () => {
|
||||
this.ui.showInfoMessage(
|
||||
"You are about to:\n" +
|
||||
" - Uninstall the Codex application\n" +
|
||||
" - Delete your Codex ethereum keys\n" +
|
||||
" - Delete the data stored in your Codex node\n" +
|
||||
" - Delete the log files of your Codex node",
|
||||
);
|
||||
|
||||
await this.ui.askMultipleChoice(
|
||||
"Are you sure you want to uninstall Codex?",
|
||||
[
|
||||
{
|
||||
label: "No",
|
||||
action: this.doNothing,
|
||||
},
|
||||
{
|
||||
label: "Yes",
|
||||
action: this.performUninstall,
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
selectInstallPath = async () => {
|
||||
this.config.codexRoot = await this.pathSelector.show(
|
||||
this.config.codexRoot,
|
||||
false,
|
||||
);
|
||||
this.configService.saveConfig();
|
||||
};
|
||||
|
||||
storageProviderOption = async () => {
|
||||
this.ui.showInfoMessage("This option is not currently available.");
|
||||
await this.show();
|
||||
};
|
||||
|
||||
performInstall = async () => {
|
||||
this.loop.stopLoop();
|
||||
await this.installer.installCodex(this);
|
||||
};
|
||||
|
||||
performUninstall = async () => {
|
||||
this.loop.stopLoop();
|
||||
this.installer.uninstallCodex();
|
||||
};
|
||||
|
||||
doNothing = async () => {
|
||||
this.loop.stopLoop();
|
||||
};
|
||||
|
||||
// Progress callbacks from installer module:
|
||||
installStarts = () => {
|
||||
this.installSpinner = this.ui.createAndStartSpinner("Installing...");
|
||||
};
|
||||
|
||||
downloadSuccessful = () => {
|
||||
this.ui.showInfoMessage("Download successful...");
|
||||
};
|
||||
|
||||
installSuccessful = () => {
|
||||
this.ui.showInfoMessage("Installation successful!");
|
||||
this.ui.stopSpinnerSuccess(this.installSpinner);
|
||||
};
|
||||
|
||||
warn = (message) => {
|
||||
this.ui.showErrorMessage(message);
|
||||
this.ui.stopSpinnerError(this.installSpinner);
|
||||
};
|
||||
}
|
||||
226
src/ui/installMenu.test.js
Normal file
226
src/ui/installMenu.test.js
Normal file
@ -0,0 +1,226 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { InstallMenu } from "./installMenu.js";
|
||||
import { mockUiService } from "../__mocks__/service.mocks.js";
|
||||
import { mockConfigService } from "../__mocks__/service.mocks.js";
|
||||
import { mockMenuLoop, mockPathSelector } from "../__mocks__/utils.mocks.js";
|
||||
import { mockInstaller } from "../__mocks__/handler.mocks.js";
|
||||
|
||||
describe("InstallMenu", () => {
|
||||
const config = {
|
||||
codexRoot: "/codex",
|
||||
};
|
||||
let installMenu;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockConfigService.get.mockReturnValue(config);
|
||||
|
||||
installMenu = new InstallMenu(
|
||||
mockUiService,
|
||||
mockMenuLoop,
|
||||
mockConfigService,
|
||||
mockPathSelector,
|
||||
mockInstaller,
|
||||
);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("initializes the menu loop with the showMenu function", () => {
|
||||
expect(mockMenuLoop.initialize).toHaveBeenCalledWith(
|
||||
installMenu.showMenu,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("show", () => {
|
||||
it("starts the menu loop", async () => {
|
||||
await installMenu.show();
|
||||
|
||||
expect(mockMenuLoop.showLoop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showMenu", () => {
|
||||
beforeEach(() => {
|
||||
installMenu.showInstallMenu = vi.fn();
|
||||
installMenu.showUninstallMenu = vi.fn();
|
||||
});
|
||||
|
||||
it("shows uninstall menu when codex is installed", async () => {
|
||||
mockInstaller.isCodexInstalled.mockResolvedValue(true);
|
||||
|
||||
await installMenu.showMenu();
|
||||
|
||||
expect(installMenu.showUninstallMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows install menu when codex is not installed", async () => {
|
||||
mockInstaller.uninstallCodex.mockResolvedValue(false);
|
||||
|
||||
await installMenu.showMenu();
|
||||
|
||||
expect(installMenu.showInstallMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the install menu", async () => {
|
||||
await installMenu.showInstallMenu();
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Configure your Codex installation",
|
||||
[
|
||||
{
|
||||
label: "Install path: " + config.codexRoot,
|
||||
action: installMenu.selectInstallPath,
|
||||
},
|
||||
{
|
||||
label: "Storage provider module: Disabled (todo)",
|
||||
action: installMenu.storageProviderOption,
|
||||
},
|
||||
{
|
||||
label: "Install!",
|
||||
action: installMenu.performInstall,
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
action: installMenu.doNothing,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("displays the uninstall menu", async () => {
|
||||
await installMenu.showUninstallMenu();
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Codex is installed",
|
||||
[
|
||||
{
|
||||
label: "Uninstall",
|
||||
action: installMenu.showConfirmUninstall,
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
action: installMenu.doNothing,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("confirms uninstall", async () => {
|
||||
await installMenu.showConfirmUninstall();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"You are about to:\n" +
|
||||
" - Uninstall the Codex application\n" +
|
||||
" - Delete your Codex ethereum keys\n" +
|
||||
" - Delete the data stored in your Codex node\n" +
|
||||
" - Delete the log files of your Codex node",
|
||||
);
|
||||
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Are you sure you want to uninstall Codex?",
|
||||
[
|
||||
{
|
||||
label: "No",
|
||||
action: installMenu.doNothing,
|
||||
},
|
||||
{
|
||||
label: "Yes",
|
||||
action: installMenu.performUninstall,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("allows selecting the install path", async () => {
|
||||
const originalPath = config.codexRoot;
|
||||
const newPath = "/new/path";
|
||||
mockPathSelector.show.mockResolvedValue(newPath);
|
||||
|
||||
await installMenu.selectInstallPath();
|
||||
|
||||
expect(mockPathSelector.show).toHaveBeenCalledWith(originalPath, false);
|
||||
expect(config.codexRoot).toBe(newPath);
|
||||
expect(mockConfigService.saveConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows storage provider option is unavailable", async () => {
|
||||
const showMock = vi.fn();
|
||||
installMenu.show = showMock;
|
||||
|
||||
await installMenu.storageProviderOption();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"This option is not currently available.",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls installed for installation", async () => {
|
||||
await installMenu.performInstall();
|
||||
|
||||
expect(mockInstaller.installCodex).toHaveBeenCalledWith(installMenu);
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls installer for deinstallation", async () => {
|
||||
await installMenu.performUninstall();
|
||||
|
||||
expect(mockInstaller.uninstallCodex).toHaveBeenCalled();
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the menu loop when nothing is selected", async () => {
|
||||
await installMenu.doNothing();
|
||||
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("process callback handling", () => {
|
||||
const mockSpinner = {
|
||||
isRealSpinner: "no srry",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUiService.createAndStartSpinner.mockReturnValue(mockSpinner);
|
||||
});
|
||||
|
||||
it("creates spinner on installStarts", () => {
|
||||
installMenu.installStarts();
|
||||
|
||||
expect(installMenu.installSpinner).toBe(mockSpinner);
|
||||
expect(mockUiService.createAndStartSpinner).toHaveBeenCalledWith(
|
||||
"Installing...",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows download success message", () => {
|
||||
installMenu.downloadSuccessful();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Download successful...",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows install success message", () => {
|
||||
installMenu.installSpinner = mockSpinner;
|
||||
|
||||
installMenu.installSuccessful();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"Installation successful!",
|
||||
);
|
||||
expect(mockUiService.stopSpinnerSuccess).toHaveBeenCalledWith(
|
||||
mockSpinner,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows warnings", () => {
|
||||
const message = "warning!";
|
||||
installMenu.installSpinner = mockSpinner;
|
||||
|
||||
installMenu.warn(message);
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(message);
|
||||
expect(mockUiService.stopSpinnerError).toHaveBeenCalledWith(mockSpinner);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/ui/mainMenu.js
Normal file
132
src/ui/mainMenu.js
Normal file
@ -0,0 +1,132 @@
|
||||
export class MainMenu {
|
||||
constructor(
|
||||
uiService,
|
||||
menuLoop,
|
||||
installMenu,
|
||||
configMenu,
|
||||
installer,
|
||||
processControl,
|
||||
codexApp,
|
||||
dataMenu,
|
||||
nodeStatusMenu,
|
||||
) {
|
||||
this.ui = uiService;
|
||||
this.loop = menuLoop;
|
||||
this.installMenu = installMenu;
|
||||
this.configMenu = configMenu;
|
||||
this.installer = installer;
|
||||
this.processControl = processControl;
|
||||
this.codexApp = codexApp;
|
||||
this.dataMenu = dataMenu;
|
||||
this.nodeStatusMenu = nodeStatusMenu;
|
||||
|
||||
this.loop.initialize(this.promptMainMenu);
|
||||
}
|
||||
|
||||
show = async () => {
|
||||
this.ui.showLogo();
|
||||
|
||||
await this.loop.showLoop();
|
||||
};
|
||||
|
||||
promptMainMenu = async () => {
|
||||
if ((await this.processControl.getNumberOfCodexProcesses()) > 0) {
|
||||
await this.showRunningMenu();
|
||||
} else {
|
||||
if (await this.installer.isCodexInstalled()) {
|
||||
await this.showNotRunningMenu();
|
||||
} else {
|
||||
await this.showNotInstalledMenu();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showNotInstalledMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Codex is not installed", [
|
||||
{
|
||||
label: "Install Codex",
|
||||
action: this.installMenu.show,
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
action: this.loop.stopLoop,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
showRunningMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Codex is running", [
|
||||
{
|
||||
label: "Open Codex app",
|
||||
action: this.codexApp.openCodexApp,
|
||||
},
|
||||
{
|
||||
label: "Stop Codex",
|
||||
action: this.stopCodex,
|
||||
},
|
||||
{
|
||||
label: "Show node status",
|
||||
action: this.nodeStatusMenu.showNodeStatus,
|
||||
},
|
||||
{
|
||||
label: "Upload a file",
|
||||
action: this.dataMenu.performUpload,
|
||||
},
|
||||
{
|
||||
label: "Download a file",
|
||||
action: this.dataMenu.performDownload,
|
||||
},
|
||||
{
|
||||
label: "Show local data",
|
||||
action: this.dataMenu.showLocalData,
|
||||
},
|
||||
{
|
||||
label: "Exit (Codex keeps running)",
|
||||
action: this.loop.stopLoop,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
showNotRunningMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Codex is installed but not running", [
|
||||
{
|
||||
label: "Start Codex",
|
||||
action: this.startCodex,
|
||||
},
|
||||
{
|
||||
label: "Edit Codex config",
|
||||
action: this.configMenu.show,
|
||||
},
|
||||
{
|
||||
label: "Uninstall Codex",
|
||||
action: this.installMenu.show,
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
action: this.loop.stopLoop,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
startCodex = async () => {
|
||||
const spinner = this.ui.createAndStartSpinner("Starting...");
|
||||
try {
|
||||
await this.processControl.startCodexProcess();
|
||||
this.ui.stopSpinnerSuccess(spinner);
|
||||
} catch (exception) {
|
||||
this.ui.stopSpinnerError(spinner);
|
||||
this.ui.showErrorMessage(`Failed to start Codex. "${exception}"`);
|
||||
}
|
||||
};
|
||||
|
||||
stopCodex = async () => {
|
||||
const spinner = this.ui.createAndStartSpinner("Stopping...");
|
||||
try {
|
||||
await this.processControl.stopCodexProcess();
|
||||
this.ui.stopSpinnerSuccess(spinner);
|
||||
} catch (exception) {
|
||||
this.ui.stopSpinnerError(spinner);
|
||||
this.ui.showErrorMessage(`Failed to stop Codex. "${exception}"`);
|
||||
}
|
||||
};
|
||||
}
|
||||
260
src/ui/mainMenu.test.js
Normal file
260
src/ui/mainMenu.test.js
Normal file
@ -0,0 +1,260 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { MainMenu } from "./mainMenu.js";
|
||||
import { mockUiService, mockCodexApp } from "../__mocks__/service.mocks.js";
|
||||
import {
|
||||
mockInstallMenu,
|
||||
mockConfigMenu,
|
||||
mockDataMenu,
|
||||
mockNodeStatusMenu,
|
||||
} from "../__mocks__/ui.mocks.js";
|
||||
import {
|
||||
mockInstaller,
|
||||
mockProcessControl,
|
||||
} from "../__mocks__/handler.mocks.js";
|
||||
import { mockMenuLoop } from "../__mocks__/utils.mocks.js";
|
||||
|
||||
describe("mainmenu", () => {
|
||||
let mainmenu;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mainmenu = new MainMenu(
|
||||
mockUiService,
|
||||
mockMenuLoop,
|
||||
mockInstallMenu,
|
||||
mockConfigMenu,
|
||||
mockInstaller,
|
||||
mockProcessControl,
|
||||
mockCodexApp,
|
||||
mockDataMenu,
|
||||
mockNodeStatusMenu,
|
||||
);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("initializes the menu loop with the promptMainMenu function", () => {
|
||||
expect(mockMenuLoop.initialize).toHaveBeenCalledWith(
|
||||
mainmenu.promptMainMenu,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("show", () => {
|
||||
it("shows the logo", async () => {
|
||||
await mainmenu.show();
|
||||
|
||||
expect(mockUiService.showLogo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the menu loop", async () => {
|
||||
await mainmenu.show();
|
||||
|
||||
expect(mockMenuLoop.showLoop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptMainMenu", () => {
|
||||
beforeEach(() => {
|
||||
mainmenu.showRunningMenu = vi.fn();
|
||||
mainmenu.showNotRunningMenu = vi.fn();
|
||||
mainmenu.showNotInstalledMenu = vi.fn();
|
||||
});
|
||||
|
||||
it("shows running menu when number of codex processes is greater than zero", async () => {
|
||||
mockProcessControl.getNumberOfCodexProcesses.mockResolvedValue(1);
|
||||
|
||||
await mainmenu.promptMainMenu();
|
||||
|
||||
expect(mainmenu.showRunningMenu).toHaveBeenCalled();
|
||||
expect(mainmenu.showNotRunningMenu).not.toHaveBeenCalled();
|
||||
expect(mainmenu.showNotInstalledMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows not running menu when number of codex processes is zero and codex is installed", async () => {
|
||||
mockProcessControl.getNumberOfCodexProcesses.mockResolvedValue(0);
|
||||
mockInstaller.isCodexInstalled.mockResolvedValue(true);
|
||||
|
||||
await mainmenu.promptMainMenu();
|
||||
|
||||
expect(mainmenu.showRunningMenu).not.toHaveBeenCalled();
|
||||
expect(mainmenu.showNotRunningMenu).toHaveBeenCalled();
|
||||
expect(mainmenu.showNotInstalledMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows not installed menu when number of codex processes is zero and codex is not installed", async () => {
|
||||
mockProcessControl.getNumberOfCodexProcesses.mockResolvedValue(0);
|
||||
mockInstaller.isCodexInstalled.mockResolvedValue(false);
|
||||
|
||||
await mainmenu.promptMainMenu();
|
||||
|
||||
expect(mainmenu.showRunningMenu).not.toHaveBeenCalled();
|
||||
expect(mainmenu.showNotRunningMenu).not.toHaveBeenCalled();
|
||||
expect(mainmenu.showNotInstalledMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showNotInstalledMenu", () => {
|
||||
it("shows a menu with options to install Codex or exit", async () => {
|
||||
await mainmenu.showNotInstalledMenu();
|
||||
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Codex is not installed",
|
||||
[
|
||||
{ label: "Install Codex", action: mockInstallMenu.show },
|
||||
{ label: "Exit", action: mockMenuLoop.stopLoop },
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showRunningMenu", () => {
|
||||
it("shows a menu with options to stop Codex, open Codex app, upload, download, or exit", async () => {
|
||||
await mainmenu.showRunningMenu();
|
||||
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Codex is running",
|
||||
[
|
||||
{ label: "Open Codex app", action: mockCodexApp.openCodexApp },
|
||||
{ label: "Stop Codex", action: mainmenu.stopCodex },
|
||||
{
|
||||
label: "Show node status",
|
||||
action: mockNodeStatusMenu.showNodeStatus,
|
||||
},
|
||||
{ label: "Upload a file", action: mockDataMenu.performUpload },
|
||||
{ label: "Download a file", action: mockDataMenu.performDownload },
|
||||
{ label: "Show local data", action: mockDataMenu.showLocalData },
|
||||
{
|
||||
label: "Exit (Codex keeps running)",
|
||||
action: mockMenuLoop.stopLoop,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showNotRunningMenu", () => {
|
||||
it("shows a menu with options to start Codex, configure, uninstall, or exit", async () => {
|
||||
await mainmenu.showNotRunningMenu();
|
||||
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith(
|
||||
"Codex is installed but not running",
|
||||
[
|
||||
{
|
||||
label: "Start Codex",
|
||||
action: mainmenu.startCodex,
|
||||
},
|
||||
{ label: "Edit Codex config", action: mockConfigMenu.show },
|
||||
{ label: "Uninstall Codex", action: mockInstallMenu.show },
|
||||
{ label: "Exit", action: mockMenuLoop.stopLoop },
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("process control", () => {
|
||||
const mockSpinner = {
|
||||
isMock: "yes",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUiService.createAndStartSpinner.mockReturnValue(mockSpinner);
|
||||
});
|
||||
|
||||
describe("startCodex", () => {
|
||||
it("starts codex", async () => {
|
||||
await mainmenu.startCodex();
|
||||
|
||||
expect(mockProcessControl.startCodexProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error message when process control throws", async () => {
|
||||
mockProcessControl.startCodexProcess.mockRejectedValueOnce(
|
||||
new Error("A!"),
|
||||
);
|
||||
|
||||
await mainmenu.startCodex();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
'Failed to start Codex. "Error: A!"',
|
||||
);
|
||||
});
|
||||
|
||||
it("starts spinner", async () => {
|
||||
await mainmenu.startCodex();
|
||||
|
||||
expect(mockUiService.createAndStartSpinner).toHaveBeenCalledWith(
|
||||
"Starting...",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops spinner on success", async () => {
|
||||
await mainmenu.startCodex();
|
||||
|
||||
expect(mockUiService.stopSpinnerSuccess).toHaveBeenCalledWith(
|
||||
mockSpinner,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops spinner on failure", async () => {
|
||||
mockProcessControl.startCodexProcess.mockRejectedValueOnce(
|
||||
new Error("A!"),
|
||||
);
|
||||
|
||||
await mainmenu.startCodex();
|
||||
|
||||
expect(mockUiService.stopSpinnerError).toHaveBeenCalledWith(
|
||||
mockSpinner,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopCodex", () => {
|
||||
it("stops codex", async () => {
|
||||
await mainmenu.stopCodex();
|
||||
|
||||
expect(mockProcessControl.stopCodexProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error message when process control throws", async () => {
|
||||
mockProcessControl.stopCodexProcess.mockRejectedValueOnce(
|
||||
new Error("A!"),
|
||||
);
|
||||
|
||||
await mainmenu.stopCodex();
|
||||
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
'Failed to stop Codex. "Error: A!"',
|
||||
);
|
||||
});
|
||||
|
||||
it("starts spinner", async () => {
|
||||
await mainmenu.stopCodex();
|
||||
|
||||
expect(mockUiService.createAndStartSpinner).toHaveBeenCalledWith(
|
||||
"Stopping...",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops spinner on success", async () => {
|
||||
await mainmenu.stopCodex();
|
||||
|
||||
expect(mockUiService.stopSpinnerSuccess).toHaveBeenCalledWith(
|
||||
mockSpinner,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops spinner on failure", async () => {
|
||||
mockProcessControl.stopCodexProcess.mockRejectedValueOnce(
|
||||
new Error("A!"),
|
||||
);
|
||||
|
||||
await mainmenu.stopCodex();
|
||||
|
||||
expect(mockUiService.stopSpinnerError).toHaveBeenCalledWith(
|
||||
mockSpinner,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/ui/marketplaceSetup.js
Normal file
63
src/ui/marketplaceSetup.js
Normal file
@ -0,0 +1,63 @@
|
||||
const ethFaucetAddress = "https://faucet-eth.testnet.codex.storage/";
|
||||
const tstFaucetAddress = "https://faucet-tst.testnet.codex.storage/";
|
||||
const discordServerAddress = "https://discord.gg/codex-storage";
|
||||
const botChannelLink =
|
||||
"https://discord.com/channels/895609329053474826/1230785221553819669";
|
||||
|
||||
export class MarketplaceSetup {
|
||||
constructor(uiService, configService, ethersService) {
|
||||
this.ui = uiService;
|
||||
this.ethers = ethersService;
|
||||
this.config = configService.get();
|
||||
}
|
||||
|
||||
runClientWizard = async () => {
|
||||
await this.generateKeyPair();
|
||||
await this.showMintInstructions();
|
||||
return this.isSuccessful;
|
||||
};
|
||||
|
||||
generateKeyPair = async () => {
|
||||
const ehtKey = await this.ethers.getOrCreateEthKey();
|
||||
|
||||
this.ui.showSuccessMessage(
|
||||
"Your Codex node Ethereum account:\n" +
|
||||
`Private key saved to '${ehtKey.privateKeyFilePath}'\n` +
|
||||
`Address saved to '${ehtKey.addressFilePath}'\n` +
|
||||
`Ethereum Account: '${ehtKey.address}'`,
|
||||
);
|
||||
};
|
||||
|
||||
showMintInstructions = async () => {
|
||||
this.ui.showInfoMessage(
|
||||
"Use one of these two methods to receive your testnet tokens:\n\n" +
|
||||
"Faucets:\n" +
|
||||
`Use the Eth faucet: '${ethFaucetAddress}'\n` +
|
||||
`Then use the TST faucet: '${tstFaucetAddress}'\n\n` +
|
||||
"or\n\n" +
|
||||
"Discord bot:\n" +
|
||||
`Join the server: ${discordServerAddress}\n` +
|
||||
`Go to the #BOT channel: ${botChannelLink}\n` +
|
||||
"Use '/set' and '/mint' commands to receive tokens.\n",
|
||||
);
|
||||
|
||||
await this.ui.askMultipleChoice("Take your time.", [
|
||||
{
|
||||
label: "Proceed",
|
||||
action: this.proceed,
|
||||
},
|
||||
{
|
||||
label: "Abort",
|
||||
action: this.abort,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
proceed = async () => {
|
||||
this.isSuccessful = true;
|
||||
};
|
||||
|
||||
abort = async () => {
|
||||
this.isSuccessful = false;
|
||||
};
|
||||
}
|
||||
93
src/ui/nodeStatusMenu.js
Normal file
93
src/ui/nodeStatusMenu.js
Normal file
@ -0,0 +1,93 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export class NodeStatusMenu {
|
||||
constructor(uiService, dataService, menuLoop) {
|
||||
this.ui = uiService;
|
||||
this.dataService = dataService;
|
||||
this.loop = menuLoop;
|
||||
|
||||
this.loop.initialize(this.showNodeStatusMenu);
|
||||
}
|
||||
|
||||
showNodeStatus = async () => {
|
||||
this.debugInfo = await this.fetchDebugInfo();
|
||||
if (this.debugInfo == undefined) return;
|
||||
|
||||
const peerCount = this.debugInfo.table.nodes.length;
|
||||
const isOnline = peerCount > 2;
|
||||
|
||||
if (isOnline) {
|
||||
this.ui.showSuccessMessage(
|
||||
"Node is ONLINE & DISCOVERABLE",
|
||||
"🔌 Node Status",
|
||||
);
|
||||
} else {
|
||||
this.ui.showInfoMessage(
|
||||
"Node is ONLINE but has few peers",
|
||||
"🔌 Node Status",
|
||||
);
|
||||
}
|
||||
|
||||
await this.loop.showLoop();
|
||||
};
|
||||
|
||||
showNodeStatusMenu = async () => {
|
||||
await this.ui.askMultipleChoice("Select information to view:", [
|
||||
{
|
||||
label: "View connected peers",
|
||||
action: this.showPeers,
|
||||
},
|
||||
{
|
||||
label: "View node information",
|
||||
action: this.showNodeInfo,
|
||||
},
|
||||
{
|
||||
label: "Back to Main Menu",
|
||||
action: this.loop.stopLoop,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
showPeers = async () => {
|
||||
const peerCount = this.debugInfo.table.nodes.length;
|
||||
if (peerCount > 0) {
|
||||
this.ui.showInfoMessage("Connected Peers");
|
||||
this.debugInfo.table.nodes.forEach((node, index) => {
|
||||
this.ui.showInfoMessage(
|
||||
`Peer ${index + 1}:\n` +
|
||||
`${chalk.cyan("Peer ID:")} ${node.peerId}\n` +
|
||||
`${chalk.cyan("Address:")} ${node.address}\n` +
|
||||
`${chalk.cyan("Status:")} ${node.seen ? chalk.green("Active") : chalk.gray("Inactive")}`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.ui.showInfoMessage("No connected peers found.");
|
||||
}
|
||||
};
|
||||
|
||||
showNodeInfo = async () => {
|
||||
const data = this.debugInfo;
|
||||
this.ui.showInfoMessage(
|
||||
`${chalk.cyan("Version:")} ${data.codex.version}\n` +
|
||||
`${chalk.cyan("Revision:")} ${data.codex.revision}\n\n` +
|
||||
`${chalk.cyan("Node ID:")} ${data.table.localNode.nodeId}\n` +
|
||||
`${chalk.cyan("Peer ID:")} ${data.table.localNode.peerId}\n` +
|
||||
`${chalk.cyan("Listening Address:")} ${data.table.localNode.address}\n\n` +
|
||||
`${chalk.cyan("Public IP:")} ${data.announceAddresses[0].split("/")[2]}\n` +
|
||||
`${chalk.cyan("Port:")} ${data.announceAddresses[0].split("/")[4]}\n` +
|
||||
`${chalk.cyan("Connected Peers:")} ${data.table.nodes.length}`,
|
||||
);
|
||||
};
|
||||
|
||||
fetchDebugInfo = async () => {
|
||||
const spinner = this.ui.createAndStartSpinner("Fetching...");
|
||||
try {
|
||||
return await this.dataService.debugInfo();
|
||||
} catch {
|
||||
this.ui.showErrorMessage("Failed to fetch debug/info");
|
||||
return;
|
||||
} finally {
|
||||
this.ui.stopSpinnerSuccess(spinner);
|
||||
}
|
||||
};
|
||||
}
|
||||
44
src/utils/appData.js
Normal file
44
src/utils/appData.js
Normal file
@ -0,0 +1,44 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export function getAppDataDir() {
|
||||
return ensureExists(appData("codex-cli"));
|
||||
}
|
||||
|
||||
export function getDefaultCodexRootPath() {
|
||||
return ensureExists(appData("codex"));
|
||||
}
|
||||
|
||||
function ensureExists(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function appData(...app) {
|
||||
let appData;
|
||||
if (process.platform === "win32") {
|
||||
appData = path.join(process.env.APPDATA, ...app);
|
||||
} else if (process.platform === "darwin") {
|
||||
appData = path.join(
|
||||
process.env.HOME,
|
||||
"Library",
|
||||
"Application Support",
|
||||
...app,
|
||||
);
|
||||
} else {
|
||||
appData = path.join(process.env.HOME, ...prependDot(...app));
|
||||
}
|
||||
return appData;
|
||||
}
|
||||
|
||||
function prependDot(...app) {
|
||||
return app.map((item, i) => {
|
||||
if (i === 0) {
|
||||
return `.${item}`;
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export function getAppDataDir() {
|
||||
return ensureExists(appData("codex-cli"));
|
||||
}
|
||||
|
||||
export function getCodexRootPath() {
|
||||
return ensureExists(appData("codex"));
|
||||
}
|
||||
|
||||
export function getCodexBinPath() {
|
||||
return ensureExists(path.join(appData("codex"), "bin"));
|
||||
}
|
||||
|
||||
export function getCodexDataDirDefaultPath() {
|
||||
// This path does not exist on first startup. That's good: Codex will
|
||||
// create it with the required access permissions.
|
||||
return path.join(appData("codex"), "datadir");
|
||||
}
|
||||
|
||||
export function getCodexLogsDefaultPath() {
|
||||
return ensureExists(path.join(appData("codex"), "logs"));
|
||||
}
|
||||
|
||||
function ensureExists(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function appData(...app) {
|
||||
let appData;
|
||||
if (process.platform === 'win32') {
|
||||
appData = path.join(process.env.APPDATA, ...app);
|
||||
} else if (process.platform === 'darwin') {
|
||||
appData = path.join(process.env.HOME, 'Library', 'Application Support', ...app);
|
||||
} else {
|
||||
appData = path.join(process.env.HOME, ...prependDot(...app));
|
||||
}
|
||||
return appData;
|
||||
}
|
||||
|
||||
function prependDot(...app) {
|
||||
return app.map((item, i) => {
|
||||
if (i === 0) {
|
||||
return `.${item}`;
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
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;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/utils/menuLoop.js
Normal file
20
src/utils/menuLoop.js
Normal file
@ -0,0 +1,20 @@
|
||||
export class MenuLoop {
|
||||
initialize = (menuPrompt) => {
|
||||
this.menuPrompt = menuPrompt;
|
||||
};
|
||||
|
||||
showOnce = async () => {
|
||||
await this.menuPrompt();
|
||||
};
|
||||
|
||||
showLoop = async () => {
|
||||
this.running = true;
|
||||
while (this.running) {
|
||||
await this.menuPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
stopLoop = () => {
|
||||
this.running = false;
|
||||
};
|
||||
}
|
||||
42
src/utils/menuLoop.test.js
Normal file
42
src/utils/menuLoop.test.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { MenuLoop } from "./menuLoop.js";
|
||||
|
||||
describe("MenuLoop", () => {
|
||||
let menuLoop;
|
||||
const mockPrompt = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
menuLoop = new MenuLoop();
|
||||
menuLoop.initialize(mockPrompt);
|
||||
});
|
||||
|
||||
it("can show menu once", async () => {
|
||||
await menuLoop.showOnce();
|
||||
expect(mockPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can stop the menu loop", async () => {
|
||||
mockPrompt.mockImplementation(() => {
|
||||
menuLoop.stopLoop();
|
||||
});
|
||||
await menuLoop.showLoop();
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(menuLoop.running).toBe(false);
|
||||
});
|
||||
|
||||
it("can run menu in a loop", async () => {
|
||||
let calls = 0;
|
||||
mockPrompt.mockImplementation(() => {
|
||||
calls++;
|
||||
if (calls >= 3) {
|
||||
menuLoop.stopLoop();
|
||||
}
|
||||
});
|
||||
|
||||
await menuLoop.showLoop();
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@ -1,35 +1,35 @@
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
|
||||
export function showSuccessMessage(message) {
|
||||
return boxen(chalk.green(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'green',
|
||||
title: '✅ SUCCESS',
|
||||
titleAlignment: 'center'
|
||||
});
|
||||
return boxen(chalk.green(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "green",
|
||||
title: "✅ SUCCESS",
|
||||
titleAlignment: "center",
|
||||
});
|
||||
}
|
||||
|
||||
export function showErrorMessage(message) {
|
||||
return boxen(chalk.red(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'red',
|
||||
title: '❌ ERROR',
|
||||
titleAlignment: 'center'
|
||||
});
|
||||
return boxen(chalk.red(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "red",
|
||||
title: "❌ ERROR",
|
||||
titleAlignment: "center",
|
||||
});
|
||||
}
|
||||
|
||||
export function showInfoMessage(message) {
|
||||
return boxen(chalk.cyan(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'cyan',
|
||||
title: 'ℹ️ INFO',
|
||||
titleAlignment: 'center'
|
||||
});
|
||||
}
|
||||
return boxen(chalk.cyan(message), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "cyan",
|
||||
title: "ℹ️ INFO",
|
||||
titleAlignment: "center",
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,47 +1,45 @@
|
||||
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.");
|
||||
show = 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);
|
||||
};
|
||||
}
|
||||
|
||||
62
src/utils/numberSelector.test.js
Normal file
62
src/utils/numberSelector.test.js
Normal file
@ -0,0 +1,62 @@
|
||||
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.show(0, prompt, false);
|
||||
|
||||
expect(mockUiService.askPrompt).toHaveBeenCalledWith(prompt);
|
||||
});
|
||||
|
||||
it("returns a number given valid input", async () => {
|
||||
mockUiService.askPrompt.mockResolvedValue("123");
|
||||
|
||||
const number = await numberSelector.show(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.show(currentValue, prompt, false);
|
||||
|
||||
expect(number).toEqual(currentValue);
|
||||
});
|
||||
|
||||
async function run(input) {
|
||||
mockUiService.askPrompt.mockResolvedValue(input);
|
||||
return await numberSelector.show(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);
|
||||
});
|
||||
});
|
||||
@ -1,243 +1,194 @@
|
||||
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, menuLoop, fsService) {
|
||||
this.ui = uiService;
|
||||
this.loop = menuLoop;
|
||||
this.fs = fsService;
|
||||
|
||||
function showMsg(msg) {
|
||||
console.log(boxen(chalk.white(msg), {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'white',
|
||||
titleAlignment: 'center'
|
||||
}));
|
||||
}
|
||||
this.pathMustExist = true;
|
||||
this.loop.initialize(this.showPathSelector);
|
||||
}
|
||||
|
||||
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);
|
||||
show = async (startingPath, pathMustExist) => {
|
||||
this.startingPath = startingPath;
|
||||
this.pathMustExist = pathMustExist;
|
||||
this.roots = this.fs.getAvailableRoots();
|
||||
this.currentPath = this.splitPath(startingPath);
|
||||
if (!this.hasValidRoot(this.currentPath)) {
|
||||
this.currentPath = [this.roots[0]];
|
||||
}
|
||||
|
||||
await this.loop.showLoop();
|
||||
|
||||
return this.resultingPath;
|
||||
};
|
||||
|
||||
showPathSelector = async () => {
|
||||
this.showCurrent();
|
||||
await this.ui.askMultipleChoice("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,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
splitPath = (str) => {
|
||||
var result = this.dropEmptyParts(str.replaceAll("\\", "/").split("/"));
|
||||
if (str.startsWith("/") && this.roots.includes("/")) {
|
||||
result = ["/", ...result];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
dropEmptyParts = (parts) => {
|
||||
return parts.filter((part) => part.length > 0);
|
||||
};
|
||||
|
||||
combine = (parts) => {
|
||||
const toJoin = this.dropEmptyParts(parts);
|
||||
if (toJoin.length == 1) return toJoin[0];
|
||||
var result = this.fs.pathJoin(toJoin);
|
||||
if (result.startsWith("//")) {
|
||||
result = result.substring(1);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
updateCurrentIfValidFull = (newFullPath) => {
|
||||
if (this.pathMustExist && !this.fs.isDir(newFullPath)) {
|
||||
this.ui.showErrorMessage("The path does not exist.");
|
||||
return;
|
||||
}
|
||||
this.updateCurrentIfValidParts(this.splitPath(newFullPath));
|
||||
};
|
||||
|
||||
updateCurrentIfValidParts = (newParts) => {
|
||||
if (!this.hasValidRoot(newParts)) {
|
||||
this.ui.showErrorMessage("The path has no valid root.");
|
||||
return;
|
||||
}
|
||||
this.currentPath = newParts;
|
||||
};
|
||||
|
||||
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);
|
||||
try {
|
||||
const entries = this.fs.readDir(fullPath);
|
||||
return entries.filter((entry) => this.isSubDir(entry));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
downOne = async () => {
|
||||
const options = this.getSubDirOptions();
|
||||
if (options.length == 0) {
|
||||
this.ui.showInfoMessage("There are no subdirectories here.");
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = "";
|
||||
var uiOptions = [];
|
||||
options.forEach(function (option) {
|
||||
uiOptions.push({
|
||||
label: option,
|
||||
action: () => {
|
||||
selected = option;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (mountPoints.length < 1) {
|
||||
throw new Error("Failed to detect file system devices.");
|
||||
}
|
||||
return mountPoints;
|
||||
}
|
||||
await this.ui.askMultipleChoice("Select an subdir", uiOptions);
|
||||
|
||||
function splitPath(str) {
|
||||
return str.replaceAll("\\", "/").split("/");
|
||||
}
|
||||
if (selected.length < 1) return;
|
||||
this.updateCurrentIfValidParts([...this.currentPath, selected]);
|
||||
};
|
||||
|
||||
function dropEmptyParts(parts) {
|
||||
var result = [];
|
||||
parts.forEach(function(part) {
|
||||
if (part.length > 0) {
|
||||
result.push(part);
|
||||
createSubDir = async () => {
|
||||
const name = await this.ui.askPrompt("Enter name:");
|
||||
if (name.length < 1) return;
|
||||
const newPath = [...this.currentPath, name];
|
||||
if (this.pathMustExist) {
|
||||
this.fs.makeDir(this.combine(newPath));
|
||||
}
|
||||
})
|
||||
return result;
|
||||
}
|
||||
|
||||
function combine(parts) {
|
||||
const toJoin = dropEmptyParts(parts);
|
||||
if (toJoin.length == 1) return toJoin[0];
|
||||
return path.join(...toJoin);
|
||||
}
|
||||
|
||||
function combineWith(parts, extra) {
|
||||
const toJoin = dropEmptyParts(parts);
|
||||
if (toJoin.length == 1) return path.join(toJoin[0], extra);
|
||||
return path.join(...toJoin, extra);
|
||||
}
|
||||
|
||||
function showCurrent(currentPath) {
|
||||
const len = currentPath.length;
|
||||
showMsg(`Current path: [${len}]\n` + combine(currentPath));
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (!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' };
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (hasValidRoot(roots, newCurrentPath)) {
|
||||
currentPath = newCurrentPath;
|
||||
} else {
|
||||
console.log("Selected 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;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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];
|
||||
this.updateCurrentIfValidParts(newPath);
|
||||
};
|
||||
|
||||
selectThisPath = async () => {
|
||||
this.resultingPath = this.combine(this.currentPath);
|
||||
this.loop.stopLoop();
|
||||
};
|
||||
|
||||
cancel = async () => {
|
||||
this.resultingPath = this.startingPath;
|
||||
this.loop.stopLoop();
|
||||
};
|
||||
}
|
||||
|
||||
179
src/utils/pathSelector.test.js
Normal file
179
src/utils/pathSelector.test.js
Normal file
@ -0,0 +1,179 @@
|
||||
import { describe, beforeEach, it, expect, vi } from "vitest";
|
||||
import { PathSelector } from "./pathSelector.js";
|
||||
import { mockUiService, mockFsService } from "../__mocks__/service.mocks.js";
|
||||
import { mockMenuLoop } from "../__mocks__/utils.mocks.js";
|
||||
|
||||
describe("PathSelector", () => {
|
||||
let pathSelector;
|
||||
const mockRoots = ["/", "/home"];
|
||||
const mockStartPath = "/home/user";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockFsService.getAvailableRoots.mockReturnValue(mockRoots);
|
||||
mockFsService.pathJoin.mockImplementation((parts) => parts.join("/"));
|
||||
mockFsService.isDir.mockReturnValue(true);
|
||||
mockFsService.readDir.mockReturnValue(["dir1", "dir2"]);
|
||||
|
||||
pathSelector = new PathSelector(mockUiService, mockMenuLoop, mockFsService);
|
||||
});
|
||||
|
||||
describe("initialization", () => {
|
||||
it("initializes the menu loop", () => {
|
||||
expect(mockMenuLoop.initialize).toHaveBeenCalledWith(
|
||||
pathSelector.showPathSelector,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("show()", () => {
|
||||
it("initializes path selection with given path", async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
expect(mockFsService.getAvailableRoots).toHaveBeenCalled();
|
||||
expect(pathSelector.currentPath).toEqual(["/", "home", "user"]);
|
||||
});
|
||||
|
||||
it("uses first root if starting path is invalid", async () => {
|
||||
await pathSelector.show("invalid/path", true);
|
||||
expect(pathSelector.currentPath).toEqual([mockRoots[0]]);
|
||||
});
|
||||
|
||||
it("starts the menu loop", async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
expect(mockMenuLoop.showLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the resulting path after selection", async () => {
|
||||
pathSelector.resultingPath = mockStartPath;
|
||||
const result = await pathSelector.show(mockStartPath, true);
|
||||
expect(result).toBe(mockStartPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path operations", () => {
|
||||
beforeEach(async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
});
|
||||
|
||||
it("splits paths correctly", () => {
|
||||
const result = pathSelector.splitPath("C:\\path\\to\\dir");
|
||||
expect(result).toEqual(["C:", "path", "to", "dir"]);
|
||||
});
|
||||
|
||||
it("drops empty path parts", () => {
|
||||
const result = pathSelector.dropEmptyParts(["", "path", "", "dir", ""]);
|
||||
expect(result).toEqual(["path", "dir"]);
|
||||
});
|
||||
|
||||
it("combines path parts correctly", () => {
|
||||
const result = pathSelector.combine(["C:", "user", "docs"]);
|
||||
expect(result).toBe("C:/user/docs");
|
||||
});
|
||||
|
||||
it("combines path including root correctly", () => {
|
||||
const result = pathSelector.combine(["/", "home", "user", "docs"]);
|
||||
expect(result).toBe("/home/user/docs");
|
||||
});
|
||||
|
||||
it("handles single part paths in combine", () => {
|
||||
const result = pathSelector.combine(["root"]);
|
||||
expect(result).toBe("root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
beforeEach(async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
});
|
||||
|
||||
it("moves up one directory", () => {
|
||||
pathSelector.upOne();
|
||||
expect(pathSelector.currentPath).toEqual(["/", "home"]);
|
||||
});
|
||||
|
||||
it("shows down directory navigation", async () => {
|
||||
mockFsService.readDir.mockReturnValue(["subdir1", "subdir2"]);
|
||||
mockFsService.isDir.mockReturnValue(true);
|
||||
|
||||
await pathSelector.downOne();
|
||||
|
||||
expect(mockUiService.askMultipleChoice).toHaveBeenCalled();
|
||||
expect(mockFsService.readDir).toHaveBeenCalledWith(mockStartPath);
|
||||
});
|
||||
|
||||
it("handles non-existing paths", async () => {
|
||||
mockFsService.readDir.mockImplementationOnce(() => {
|
||||
throw new Error("A!");
|
||||
});
|
||||
|
||||
await pathSelector.downOne();
|
||||
|
||||
expect(mockUiService.showInfoMessage).toHaveBeenCalledWith(
|
||||
"There are no subdirectories here.",
|
||||
);
|
||||
});
|
||||
|
||||
it("can navigate to a subdirectory", async () => {
|
||||
const subdir = "subdir1";
|
||||
mockFsService.readDir.mockReturnValue([subdir]);
|
||||
mockUiService.askMultipleChoice.mockImplementation((_, options) => {
|
||||
options[0].action(); // Select the first option
|
||||
});
|
||||
await pathSelector.downOne();
|
||||
|
||||
expect(pathSelector.currentPath).toEqual(["/", "home", "user", subdir]);
|
||||
});
|
||||
|
||||
it("creates new subdirectory", async () => {
|
||||
const newDir = "newdir";
|
||||
mockUiService.askPrompt.mockResolvedValue(newDir);
|
||||
await pathSelector.createSubDir();
|
||||
|
||||
expect(mockUiService.askPrompt).toHaveBeenCalledWith("Enter name:");
|
||||
expect(mockFsService.makeDir).toHaveBeenCalled(
|
||||
mockStartPath + "/" + newDir,
|
||||
);
|
||||
expect(pathSelector.currentPath).toEqual(["/", "home", "user", newDir]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path validation", () => {
|
||||
beforeEach(async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
});
|
||||
|
||||
it("validates root paths", () => {
|
||||
expect(pathSelector.hasValidRoot(["/home"])).toBe(true);
|
||||
expect(pathSelector.hasValidRoot([])).toBe(false);
|
||||
expect(pathSelector.hasValidRoot(["invalid"])).toBe(false);
|
||||
});
|
||||
|
||||
it("validates full paths", () => {
|
||||
mockFsService.isDir.mockReturnValue(false);
|
||||
pathSelector.updateCurrentIfValidFull("/invalid/path");
|
||||
expect(mockUiService.showErrorMessage).toHaveBeenCalledWith(
|
||||
"The path does not exist.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and cancellation", () => {
|
||||
beforeEach(async () => {
|
||||
await pathSelector.show(mockStartPath, true);
|
||||
});
|
||||
|
||||
it("selects current path", async () => {
|
||||
pathSelector.upOne();
|
||||
await pathSelector.selectThisPath();
|
||||
expect(pathSelector.resultingPath).toBe("/home");
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels and returns to starting path", async () => {
|
||||
pathSelector.upOne();
|
||||
await pathSelector.cancel();
|
||||
expect(pathSelector.resultingPath).toBe(mockStartPath);
|
||||
expect(mockMenuLoop.stopLoop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user