diff --git a/package.json b/package.json new file mode 100644 index 0000000..b011c55 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "node-file-manager", + "version": "1.0.0", + "description": "RS School Node.js course task 2: File manager", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js" + }, + "author": "Izy", + "license": "ISC" +} diff --git a/src/commands/archive/brotli.js b/src/commands/archive/brotli.js new file mode 100644 index 0000000..6affb3d --- /dev/null +++ b/src/commands/archive/brotli.js @@ -0,0 +1,25 @@ + +import { resolve } from 'path'; +import { createReadStream, createWriteStream } from 'fs'; +import { pipeline } from 'stream/promises'; +import { createBrotliCompress, createBrotliDecompress } from 'zlib'; +import { getArgByNumber, isPathAccessible } from "../../utils/command-input.js"; +import { getCurrentDir } from '../../utils/directory-path.js'; + +export const useBrotli = async (args, isCompress = true) => { + const sourceArgPath = getArgByNumber(args, 0); + const destArgPath = getArgByNumber(args, 1); + + const resolvedSourcePath = resolve(getCurrentDir(), sourceArgPath); + const resolvedDestDirPath = resolve(getCurrentDir(), destArgPath); + + if (!(await isPathAccessible(resolvedSourcePath))) { + throw new Error("Source path is not accessible."); + } + + const readStream = createReadStream(resolvedSourcePath); + const brotliStream = isCompress ? createBrotliCompress() : createBrotliDecompress(); + const writeStream = createWriteStream(resolvedDestDirPath); + + await pipeline(readStream, brotliStream, writeStream); +}; \ No newline at end of file diff --git a/src/commands/file-system/add.js b/src/commands/file-system/add.js new file mode 100644 index 0000000..8f3dcac --- /dev/null +++ b/src/commands/file-system/add.js @@ -0,0 +1,12 @@ +import { basename, resolve } from 'path'; +import { writeFile } from 'fs/promises'; +import { getCurrentDir } from '../../utils/directory-path.js'; +import { getArgByNumber } from "../../utils/command-input.js"; + +export const add = async (args) => { + const argPath = getArgByNumber(args, 0); + const fileBasename = basename(argPath); + const filePath = resolve(getCurrentDir(), fileBasename); + + await writeFile(filePath, '', { flag: 'wx' }); +}; \ No newline at end of file diff --git a/src/commands/file-system/cat.js b/src/commands/file-system/cat.js new file mode 100644 index 0000000..ecef3c2 --- /dev/null +++ b/src/commands/file-system/cat.js @@ -0,0 +1,18 @@ +import { resolve } from 'path'; +import { createReadStream } from 'fs'; +import { EOL } from 'os'; +import { getCurrentDir } from '../../utils/directory-path.js'; +import { getArgByNumber } from "../../utils/command-input.js"; + +export const cat = async (args) => { + const argPath = getArgByNumber(args, 0); + const resolvedPath = resolve(getCurrentDir(), argPath); + + return new Promise((resolve, reject) => { + const readStream = createReadStream(resolvedPath); + + readStream.on('data', (chunk) => process.stdout.write(`${EOL}${chunk.toString()}`)); + readStream.on('end', resolve); + readStream.on('error', (error) => reject(error)); + }); +}; \ No newline at end of file diff --git a/src/commands/file-system/cp.js b/src/commands/file-system/cp.js new file mode 100644 index 0000000..6471223 --- /dev/null +++ b/src/commands/file-system/cp.js @@ -0,0 +1,25 @@ +import { resolve, basename } from 'path'; +import { createReadStream, createWriteStream } from 'fs'; +import { pipeline } from 'stream/promises'; +import { getCurrentDir } from '../../utils/directory-path.js'; +import { getArgByNumber, isPathAccessible } from "../../utils/command-input.js"; + +export const cp = async (args) => { + const sourceArgPath = getArgByNumber(args, 0); + const destArgPath = getArgByNumber(args, 1); + + const resolvedSourcePath = resolve(getCurrentDir(), sourceArgPath); + const resolvedDestDirPath = resolve(getCurrentDir(), destArgPath); + + if (!(await isPathAccessible(resolvedSourcePath))) { + throw new Error("Source path is not accessible."); + } + + const sourceFilename = basename(resolvedSourcePath); + const resolvedDestFilePath = resolve(resolvedDestDirPath, sourceFilename); + + const sourceStream = createReadStream(resolvedSourcePath); + const destStream = createWriteStream(resolvedDestFilePath, { flags: 'wx' }); + + await pipeline(sourceStream, destStream); +}; \ No newline at end of file diff --git a/src/commands/file-system/mv.js b/src/commands/file-system/mv.js new file mode 100644 index 0000000..d6eb260 --- /dev/null +++ b/src/commands/file-system/mv.js @@ -0,0 +1,7 @@ +import { cp } from './cp.js'; +import { rm } from './rm.js'; + +export const mv = async (args) => { + await cp(args); + await rm(args); +}; \ No newline at end of file diff --git a/src/commands/file-system/rm.js b/src/commands/file-system/rm.js new file mode 100644 index 0000000..ba00b15 --- /dev/null +++ b/src/commands/file-system/rm.js @@ -0,0 +1,11 @@ +import { resolve } from 'path'; +import { unlink } from 'fs/promises'; +import { getCurrentDir } from '../../utils/directory-path.js'; +import { getArgByNumber } from "../../utils/command-input.js"; + +export const rm = async (args) => { + const argPath = getArgByNumber(args, 0); + const filePath = resolve(getCurrentDir(), argPath); + + await unlink(filePath); +}; \ No newline at end of file diff --git a/src/commands/file-system/rn.js b/src/commands/file-system/rn.js new file mode 100644 index 0000000..e38a28f --- /dev/null +++ b/src/commands/file-system/rn.js @@ -0,0 +1,15 @@ +import { rename} from 'fs/promises'; +import { basename, resolve, dirname} from 'path'; +import { getCurrentDir } from '../../utils/directory-path.js'; +import { getArgByNumber } from "../../utils/command-input.js"; + +export const rn = async (args) => { + const oldPathArg = getArgByNumber(args, 0); + const newNameArg = getArgByNumber(args, 1); + const newFilename = basename(newNameArg); + const oldFilePath = resolve(getCurrentDir(), oldPathArg); + const fileDir = dirname(oldFilePath); + const newFilePath = resolve(fileDir, newFilename); + + await rename(oldFilePath, newFilePath); +}; \ No newline at end of file diff --git a/src/commands/hash/hash.js b/src/commands/hash/hash.js new file mode 100644 index 0000000..2522b5d --- /dev/null +++ b/src/commands/hash/hash.js @@ -0,0 +1,22 @@ +import { resolve } from 'path'; +import { createReadStream } from 'fs'; +import { createHash } from 'crypto'; +import { getArgByNumber } from "../../utils/command-input.js"; +import { getCurrentDir } from '../../utils/directory-path.js'; + +export const calculateHash = async (args) => { + const argPath = getArgByNumber(args, 0); + const resolvedPath = resolve(getCurrentDir(), argPath); + + return new Promise((res, rej) => { + const readStream = createReadStream(resolvedPath); + const hash = createHash('sha256'); + + readStream.on('data', (chunk) => hash.update(chunk)); + readStream.on('end', () => { + console.log(hash.digest('hex')); + res(); + }); + readStream.on('error', (error) => rej(error)); + }); +}; \ No newline at end of file diff --git a/src/commands/input-handler.js b/src/commands/input-handler.js new file mode 100644 index 0000000..d2c4214 --- /dev/null +++ b/src/commands/input-handler.js @@ -0,0 +1,83 @@ +import { EOL } from 'os'; +import { checkArgCount, COMMANDS } from "../utils/command-input.js"; +import { printColoredText } from "../utils/color-output.js"; +import { printCurrentDirectory } from "../utils/directory-path.js"; +import { up } from "./navigation/up.js"; +import { cd } from './navigation/cd.js'; +import { ls } from './navigation/ls.js'; +import { cat } from './file-system/cat.js'; +import { add } from './file-system/add.js'; +import { rn } from './file-system/rn.js'; +import { cp } from './file-system/cp.js'; +import { rm } from './file-system/rm.js'; +import { mv } from './file-system/mv.js'; +import { executeOsCommand } from './os/os.js'; +import { calculateHash } from './hash/hash.js'; +import { useBrotli } from './archive/brotli.js'; + +export const handleInput = async (line, readlineClose) => { + try { + const args = line.match(/(").*?\1|\S+/g) || []; + const command = args.shift(); + + checkArgCount(command, args); + + switch (command) { + case undefined: + break; + case COMMANDS.EXIT.name: + readlineClose(); + break; + case COMMANDS.UP.name: + up(); + break; + case COMMANDS.CD.name: + cd(args); + break; + case COMMANDS.LS.name: + await ls(); + break; + case COMMANDS.CAT.name: + await cat(args); + break; + case COMMANDS.ADD.name: + await add(args); + break; + case COMMANDS.RN.name: + await rn(args); + break; + case COMMANDS.CP.name: + await cp(args); + break; + case COMMANDS.MV.name: + await mv(args); + break; + case COMMANDS.RM.name: + await rm(args); + break; + case COMMANDS.OS.name: + executeOsCommand(args); + break; + case COMMANDS.HASH.name: + await calculateHash(args); + break; + case COMMANDS.COMPRESS.name: + await useBrotli(args); + break; + case COMMANDS.DECOMPRESS.name: + await useBrotli(args, false); + break; + default: + printColoredText('RED', `Invalid input. Command "${command}" not supported.`); + break; + } + } catch (err) { + if (err.message.startsWith('Invalid input.')) { + printColoredText('RED', err.message); + } else { + printColoredText('RED', `Operation failed.${EOL}${err.message}`); + } + } + + printCurrentDirectory(); +}; diff --git a/src/commands/navigation/cd.js b/src/commands/navigation/cd.js new file mode 100644 index 0000000..03cffc6 --- /dev/null +++ b/src/commands/navigation/cd.js @@ -0,0 +1,10 @@ +import { isAbsolute, resolve } from 'path'; +import { getArgByNumber } from "../../utils/command-input.js"; + +export const cd = (args) => { + const path = getArgByNumber(args, 0); + const isWinOsIncompleteRootPath = process.platform === 'win32' && !isAbsolute(path) && path.includes(':'); + const improvedPath = resolve(isWinOsIncompleteRootPath ? '/' : process.cwd(), path); + + process.chdir(improvedPath); +}; \ No newline at end of file diff --git a/src/commands/navigation/ls.js b/src/commands/navigation/ls.js new file mode 100644 index 0000000..950ccad --- /dev/null +++ b/src/commands/navigation/ls.js @@ -0,0 +1,20 @@ +import { readdir } from 'fs/promises'; +import { getCurrentDir } from '../../utils/directory-path.js'; + +export const ls = async () => { + const dirents = await readdir(getCurrentDir(), { withFileTypes: true }); + + const getDirentType = (dirent) => + dirent.isFile() ? 'file' + : dirent.isDirectory() ? 'directory' : 'other'; + + const tabularData = dirents + .map((dirent) => ({ + Name: dirent.name, + Type: getDirentType(dirent), + })) + .filter(({ Type }) => Type !== 'other') + .sort((a, b) => a.Type.localeCompare(b.Type) || a.Name.localeCompare(b.Name)); + + console.table(tabularData); +}; \ No newline at end of file diff --git a/src/commands/navigation/up.js b/src/commands/navigation/up.js new file mode 100644 index 0000000..cda36b0 --- /dev/null +++ b/src/commands/navigation/up.js @@ -0,0 +1,11 @@ +import { resolve } from 'path'; +import { getCurrentDir } from '../../utils/directory-path.js'; + +export const up = () => { + const currentDir = getCurrentDir(); + const parentDir = resolve(currentDir, '..'); + + if (currentDir !== parentDir) { + process.chdir('..'); + } +}; \ No newline at end of file diff --git a/src/commands/os/cpu-info.js b/src/commands/os/cpu-info.js new file mode 100644 index 0000000..4ec8ca1 --- /dev/null +++ b/src/commands/os/cpu-info.js @@ -0,0 +1,11 @@ +import { cpus } from 'os'; + +export const getCpuInfo = () => { + const cpuData = cpus().map((cpu) => ({ + 'Model': cpu.model.trim(), + 'Clock rate, GHz': (cpu.speed / 1000).toFixed(2), + })); + + console.log(`Overall amount of CPUs: ${cpuData.length}`); + console.table(cpuData); +}; \ No newline at end of file diff --git a/src/commands/os/os.js b/src/commands/os/os.js new file mode 100644 index 0000000..c229745 --- /dev/null +++ b/src/commands/os/os.js @@ -0,0 +1,26 @@ +import { EOL, homedir, userInfo } from 'os'; +import { getColoredText } from '../../utils/color-output.js'; +import { getCpuInfo } from './cpu-info.js'; + +export const executeOsCommand = (args) => { + switch (args[0]) { + case '--EOL': + console.log('Default system End-Of-Line:', getColoredText('GREEN', JSON.stringify(EOL))); + break; + case '--cpus': + getCpuInfo() + break; + case '--homedir': + console.log('Home directory:', getColoredText('GREEN', homedir())); + break; + case '--username': + console.log('System user name:', getColoredText('GREEN', userInfo().username)); + break; + case '--architecture': + console.log('CPU architecture:', getColoredText('GREEN', process.arch)); + break; + default: + throw new Error(`Invalid input. Command "os" doesn't support argument "${args[0]}"`); + break; + } +}; \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4ca01d5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,31 @@ +import { createInterface } from 'readline/promises'; +import { printWelcomeMessage, printFarewellMessage } from './utils/user.js'; +import { handleInput } from './commands/input-handler.js'; +import { printCurrentDirectory, setStartingDir } from './utils/directory-path.js'; +import { getColoredText } from './utils/color-output.js'; + +const init = () => { + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: getColoredText('BLUE', '> '), + }); + + const readlineClose = () => { + readline.close(); + process.exit(0); + }; + + printWelcomeMessage(); + setStartingDir(); + printCurrentDirectory(); + readline.prompt(); + + readline.on('close', printFarewellMessage); + readline.on('line', async (input) => { + await handleInput(input, readlineClose); + readline.prompt(); + }); +}; + +init(); diff --git a/src/utils/color-output.js b/src/utils/color-output.js new file mode 100644 index 0000000..1456498 --- /dev/null +++ b/src/utils/color-output.js @@ -0,0 +1,21 @@ +const COLORS = { + RESET: '[0m', + BLACK: '[30m', + RED: '[31m', + GREEN: '[32m', + YELLOW: '[33m', + BLUE: '[34m', + PURPLE: '[35m', + CYAN: '[36m', + WHITE: '[37m', +}; + +export const getColoredText = (color, text) => { + const colorCode = COLORS[color] ?? COLORS.RESET; + + return `\x1b${colorCode}${text}\x1b${COLORS.RESET}`; +}; + +export const printColoredText = (color, text) => { + console.log(getColoredText(color, text)); +}; diff --git a/src/utils/command-input.js b/src/utils/command-input.js new file mode 100644 index 0000000..6f2c1f3 --- /dev/null +++ b/src/utils/command-input.js @@ -0,0 +1,57 @@ +import { EOL } from 'os'; +import { access } from "fs/promises"; + +export const COMMANDS = { + EXIT: { name: '.exit', argCount: 0 }, + UP: { name: 'up', argCount: 0 }, + CD: { name: 'cd', argCount: 1 }, + LS: { name: 'ls', argCount: 0 }, + CAT: { name: 'cat', argCount: 1 }, + ADD: { name: 'add', argCount: 1 }, + RN: { name: 'rn', argCount: 2 }, + CP: { name: 'cp', argCount: 2 }, + MV: { name: 'mv', argCount: 2 }, + RM: { name: 'rm', argCount: 1 }, + OS: { name: 'os', argCount: 1 }, + HASH: { name: 'hash', argCount: 1 }, + COMPRESS: { name: 'compress', argCount: 2 }, + DECOMPRESS: { name: 'decompress', argCount: 2 }, +}; + +export const trimQuotes = (path) => { + if (!path) { + return ''; + } else { + return path.trim().replace(/^"|"$/g, ''); + } +}; + +export const getArgByNumber = (args, number) => { + let argument = args[number] || ''; + + return trimQuotes(argument); +}; + +export const checkArgCount = (inputCommandName, args) => { + const foundCommand = Object.entries(COMMANDS).find( + ([key, { name }]) => name === inputCommandName + ); + + if (foundCommand) { + const [key, { argCount: expectedArgCount }] = foundCommand; + + if (args.length !== expectedArgCount) { + throw new Error(`Invalid input.${EOL}Command "${inputCommandName}" requires ${expectedArgCount} argument(s).`); + } + } +}; + +export const isPathAccessible = async path => { + try { + await access(path); + + return true; + } catch { + return false; + } +}; diff --git a/src/utils/directory-path.js b/src/utils/directory-path.js new file mode 100644 index 0000000..31ca444 --- /dev/null +++ b/src/utils/directory-path.js @@ -0,0 +1,12 @@ +import { homedir, EOL } from 'os'; +import { getColoredText } from "./color-output.js"; + +export const setStartingDir = () => process.chdir(homedir()); + +export const getCurrentDir = () => process.cwd(); + +export const printCurrentDirectory = () => { + const coloredPath = getColoredText('YELLOW', getCurrentDir()); + + console.log(`${EOL}You are currently in ${coloredPath}`); +} \ No newline at end of file diff --git a/src/utils/user.js b/src/utils/user.js new file mode 100644 index 0000000..f19a1b4 --- /dev/null +++ b/src/utils/user.js @@ -0,0 +1,35 @@ +import { EOL } from 'os'; +import { getColoredText } from "./color-output.js"; + +const USERNAME_ARG = 'username'; +const DEFAULT_USERNAME = 'Anonymous'; + +const getArgValue = (argName) => { + const args = process.argv.slice(2); + const foundArg = args.find((arg) => arg.startsWith(`--${argName}=`)); + + return foundArg ? foundArg.split('=')[1] : null; +}; + +const getUsername = () => { + const username = getArgValue(USERNAME_ARG); + + return username || DEFAULT_USERNAME; +}; + +export const printWelcomeMessage = () => { + const username = getUsername(); + const coloredUsername = getColoredText('GREEN', username); + + if (username === DEFAULT_USERNAME) { + console.log('No username specified. Using default username: %s.', coloredUsername); + } + + console.log('Welcome to the File Manager, %s!', coloredUsername); +} + +export const printFarewellMessage = () => { + const coloredUsername = getColoredText('GREEN', getUsername()); + + console.log(`${EOL}Thank you for using File Manager, ${coloredUsername}, goodbye!`); +} \ No newline at end of file