UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

631 lines • 22.2 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import util from 'node:util'; import async from 'async'; import { isNotJunk } from 'junk'; import nodeDiskInfo from 'node-disk-info'; import { Memoize } from 'typescript-memoize'; import Defaults from '../globals/defaults.js'; import ExpectedError from '../types/expectedError.js'; export const MoveResult = { COPIED: 1, RENAMED: 2, }; /** * A collection of static filesystem utility functions. */ class FsPoly { /** * @param dirPath the path to a temporary directory * @returns if the current runtime can create hardlinks */ static async canHardlink(dirPath) { const source = await this.mktemp(path.join(dirPath, 'source')); try { await this.touch(source); const target = await this.mktemp(path.join(dirPath, 'target')); try { await this.hardlink(source, target); return await this.exists(target); } finally { await this.rm(target, { force: true }); } } catch { return false; } finally { await this.rm(source, { force: true }); } } /** * @param dirPath the path to a temporary directory * @returns if the current runtime can create symbolic links */ static async canSymlink(dirPath) { const source = await this.mktemp(path.join(dirPath, 'source')); try { await this.touch(source); const target = await this.mktemp(path.join(dirPath, 'target')); try { await this.symlink(source, target); return await this.exists(target); } finally { await this.rm(target, { force: true }); } } catch { return false; } finally { await this.rm(source, { force: true }); } } /** * Copy the contents of {@param src} to {@param dest}, recursively, respecting subdirectories. */ static async copyDir(src, dest) { await this.mkdir(dest, { recursive: true }); const entries = await util.promisify(fs.readdir)(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await this.copyDir(srcPath, destPath); } else { await this.copyFile(srcPath, destPath); } } } /** * Copy {@param src} to {@param dest}, overwriting any existing file, and ensuring {@param dest} * is writable. */ static async copyFile(src, dest, attempt = 1) { const previouslyExisted = await this.exists(src); try { await fs.promises.copyFile(src, dest); } catch (error) { // These are the same error codes that `graceful-fs` catches if (!['EACCES', 'EPERM', 'EBUSY'].includes(error.code ?? '')) { throw error; } // Backoff with jitter if (attempt >= 5) { throw error; } await new Promise((resolve) => { setTimeout(resolve, Math.random() * (2 ** (attempt - 1) * 10)); }); return this.copyFile(src, dest, attempt + 1); } // Ensure the destination file is writable const stat = await this.stat(dest); const chmodOwnerWrite = 0o222; // Node.js' default for file creation is 0o666 (rw) if (!(stat.mode & chmodOwnerWrite)) { await fs.promises.chmod(dest, stat.mode | chmodOwnerWrite); } if (previouslyExisted) { // Windows doesn't update mtime on overwrite? await this.touch(dest); } } /** * @returns all the directories in {@param dirPath}, non-recursively */ static async dirs(dirPath) { const readDir = (await fs.promises.readdir(dirPath)) .filter((filePath) => isNotJunk(path.basename(filePath))) .map((filePath) => path.join(dirPath, filePath)); return (await async.mapLimit(readDir, Defaults.MAX_FS_THREADS, async (filePath) => (await this.isDirectory(filePath)) ? filePath : undefined)).filter((childDir) => childDir !== undefined); } /** * @returns the path to the disk that {@param filePath} is on */ static diskResolved(filePath) { const filePathResolved = path.resolve(filePath); return this.disksSync().find((mountPath) => filePathResolved.startsWith(mountPath)); } static disksSync() { return (this.DRIVES.filter((drive) => drive.available > 0) .map((drive) => drive.mounted) .filter((mountPath) => mountPath !== '/') // Sort by mount points with the deepest number of subdirectories first .sort((a, b) => b.split(/[\\/]/).length - a.split(/[\\/]/).length)); } /** * @returns if {@param pathLike} exists, not following symbolic links */ static async exists(pathLike) { try { await fs.promises.lstat(pathLike); return true; } catch { return false; } } /** * @returns if {@param pathLike} exists, not following symbolic links */ static existsSync(pathLike) { try { fs.lstatSync(pathLike); return true; } catch { return false; } } /** * Create a hardlink at location {@param link} to the original file {@param target}. */ static async hardlink(target, link) { const targetResolved = path.resolve(target); try { await fs.promises.link(targetResolved, link); return; } catch (error) { if (this.onDifferentDrives(targetResolved, link)) { throw new ExpectedError(`can't hard link files on different drives: ${error}`); } throw error; } } /** * @returns the index node of {@param pathLike} */ static async inode(pathLike) { return (await this.stat(pathLike)).ino; } /** * @returns if {@param pathLike} is a directory, following symbolic links */ static async isDirectory(pathLike) { try { const lstat = await fs.promises.lstat(pathLike); if (lstat.isSymbolicLink()) { const link = await this.readlinkResolved(pathLike); return await this.isDirectory(link); } return lstat.isDirectory(); } catch { return false; } } /** * @returns if {@param pathLike} is a directory, following symbolic links */ static isDirectorySync(pathLike) { try { const lstat = fs.lstatSync(pathLike); if (lstat.isSymbolicLink()) { const link = this.readlinkResolvedSync(pathLike); return this.isDirectorySync(link); } return lstat.isDirectory(); } catch { return false; } } /** * @returns if {@param pathLike} can be executed */ static async isExecutable(pathLike) { try { await fs.promises.access(pathLike, fs.constants.X_OK); return true; } catch { return false; } } /** * @returns if {@param pathLike} has at least one related hardlink */ static async isHardlink(pathLike) { try { return (await this.stat(pathLike)).nlink > 1; } catch { return false; } } /** * @returns if {@param filePath} is on a samba path */ static isSamba(filePath) { const normalizedPath = filePath.replaceAll(/[\\/]/g, path.sep); if (normalizedPath.startsWith(`${path.sep}${path.sep}`) && normalizedPath !== os.devNull) { return true; } const resolvedPath = path.resolve(normalizedPath); const filePathDrive = this.DRIVES // Sort by mount points with the deepest number of subdirectories first .sort((a, b) => b.mounted.split(/[\\/]/).length - a.mounted.split(/[\\/]/).length) .find((drive) => resolvedPath.startsWith(drive.mounted)); if (!filePathDrive) { // Assume 'false' by default return false; } return filePathDrive.filesystem .replaceAll(/[\\/]/g, path.sep) .startsWith(`${path.sep}${path.sep}`); } /** * @returns if {@param pathLike} is a symlink */ static async isSymlink(pathLike) { try { return (await fs.promises.lstat(pathLike)).isSymbolicLink(); } catch { return false; } } /** * @returns if {@param pathLike} is a symlink */ static isSymlinkSync(pathLike) { try { return fs.lstatSync(pathLike).isSymbolicLink(); } catch { return false; } } /** * @returns if the current runtime can write to {@param filePath} */ static async isWritable(filePath) { const exists = await this.exists(filePath); try { await this.touch(filePath); return true; } catch { return false; } finally { if (!exists) { await this.rm(filePath, { force: true }); } } } /** * @returns a new filepath with all illegal characters removed */ static makeLegal(filePath, pathSep = path.sep) { return (filePath // Make the filename Windows legal .replaceAll(':', ';') // Make the filename everything else legal .replaceAll(/[<>:"|?*]/g, '_') // Normalize the path separators .replaceAll(/[\\/]/g, pathSep) // Revert the Windows drive letter .replace(/^([a-z]);\\/i, '$1:\\')); } /** * Makes the directory {@param pathLike} with the options {@param options}. */ static async mkdir(pathLike, options) { await fs.promises.mkdir(pathLike, options); } /** * mkdtemp() takes a path "prefix" that's concatenated with random characters. Ignore that * behavior and instead assume we always want to specify a root temp directory. */ static async mkdtemp(rootDir) { const rootDirProcessed = rootDir.replace(/[\\/]+$/, '') + path.sep; try { await this.mkdir(rootDirProcessed, { recursive: true }); return await fs.promises.mkdtemp(rootDirProcessed); } catch { const backupDir = path.join(process.cwd(), 'tmp') + path.sep; await this.mkdir(backupDir, { recursive: true }); return fs.promises.mkdtemp(backupDir); } } /** * @returns a path to a temporary file that doesn't exist, without creating that file */ static async mktemp(prefix) { for (let i = 0; i < 10; i += 1) { const randomExtension = crypto.randomBytes(4).readUInt32LE().toString(36); const filePath = `${prefix.replace(/\.+$/, '')}.${randomExtension}`; if (!(await this.exists(filePath))) { return filePath; } } throw new ExpectedError('failed to generate non-existent temp file'); } /** * Move the file {@param oldPath} to {@param newPath}, retrying failures. */ static async mv(oldPath, newPath, attempt = 1) { // Can't rename across drives if (this.onDifferentDrives(oldPath, newPath)) { const newPathTemp = await this.mktemp(newPath); await this.copyFile(oldPath, newPathTemp); await this.mv(newPathTemp, newPath); await this.rm(oldPath, { force: true }); return MoveResult.COPIED; } try { await fs.promises.rename(oldPath, newPath); return MoveResult.RENAMED; } catch (error) { // Can't rename across drives if (['EXDEV'].includes(error.code ?? '')) { await this.copyFile(oldPath, newPath); await this.rm(oldPath, { force: true }); return MoveResult.COPIED; } // These are the same error codes that `graceful-fs` catches if (!['EACCES', 'EPERM', 'EBUSY'].includes(error.code ?? '')) { throw error; } // Backoff with jitter if (attempt >= 5) { throw error; } await new Promise((resolve) => { setTimeout(resolve, Math.random() * (2 ** (attempt - 1) * 10)); }); // Attempt to resolve Windows' "EBUSY: resource busy or locked" await this.rm(newPath, { force: true }); return this.mv(oldPath, newPath, attempt + 1); } } static onDifferentDrives(one, two) { if (path.dirname(one) === path.dirname(two)) { return false; } return this.diskResolved(one) !== this.diskResolved(two); } /** * @returns the target path for the symlink {@param pathLike} */ static async readlink(pathLike) { if (!(await this.isSymlink(pathLike))) { throw new ExpectedError(`can't readlink of non-symlink: ${pathLike.toString()}`); } return fs.promises.readlink(pathLike); } /** * @returns the target path for the symlink {@param pathLike} */ static readlinkSync(pathLike) { if (!this.isSymlinkSync(pathLike)) { throw new ExpectedError(`can't readlink of non-symlink: ${pathLike.toString()}`); } return fs.readlinkSync(pathLike); } /** * @returns the absolute target path for the symlink {@param link} */ static async readlinkResolved(link) { const source = await this.readlink(link); if (path.isAbsolute(source)) { return source; } return path.join(path.dirname(link), source); } /** * @returns the absolute target path for the symlink {@param link} */ static readlinkResolvedSync(link) { const source = this.readlinkSync(link); if (path.isAbsolute(source)) { return source; } return path.join(path.dirname(link), source); } /** * @returns the fully resolved path to {@param pathLike} */ static async realpath(pathLike) { if (!(await this.exists(pathLike))) { throw new ExpectedError(`can't get realpath of non-existent path: ${pathLike.toString()}`); } return fs.promises.realpath(pathLike); } /** * Deletes the file or directory {@param pathLike} with the options {@param options}, retrying * failures. */ static async rm(pathLike, options = {}) { const optionsWithRetry = { maxRetries: 2, ...options, }; if (!(await this.exists(pathLike))) { if (optionsWithRetry.force) { return; } throw new ExpectedError(`can't rm, path doesn't exist: ${pathLike}`); } if (await this.isDirectory(pathLike)) { await fs.promises.rm(pathLike, { ...optionsWithRetry, recursive: true, }); } else { await fs.promises.unlink(pathLike); } } /** * Deletes the file or directory {@param pathLike} with the options {@param options}, retrying * failures. */ static rmSync(pathLike, options = {}) { const optionsWithRetry = { maxRetries: 2, ...options, }; if (!this.existsSync(pathLike)) { if (optionsWithRetry.force) { return; } throw new ExpectedError(`can't rmSync, path doesn't exist: ${pathLike}`); } if (this.isDirectorySync(pathLike)) { fs.rmSync(pathLike, { ...optionsWithRetry, recursive: true, }); } else { fs.unlinkSync(pathLike); } } /** * Note: this will follow symlinks and get the size of the target. */ static async size(pathLike) { try { return (await this.stat(pathLike)).size; } catch { return 0; } } /** * @see https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c */ static sizeReadable(bytes, decimals = 1) { const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(k)); return `${Number.parseFloat((bytes / k ** i).toFixed(decimals))}${sizes[i]}`; } /** * Creates a relative symbolic link at {@param link} to the original file {@link target} * * Note: {@param target} should be processed with `path.resolve()` to create absolute path * symlinks */ static async symlink(target, link) { return util.promisify(fs.symlink)(target, link); } /** * Creates a relative symbolic link at {@param link} to the original file {@link target} */ static async symlinkRelativePath(target, link) { // NOTE(cemmer): macOS can be funny with files or links in system folders such as // `/var/folders/*/...` whose real path is actually `/private/var/folders/*/...`, and // path.resolve() won't resolve these fully, so we need the OS to resolve them in order to // generate valid relative paths const realTarget = await this.realpath(target); const realLink = path.join(await this.realpath(path.dirname(link)), path.basename(link)); return path.relative(path.dirname(realLink), realTarget); } /** * @returns the stats of {@param pathLike} */ static async stat(pathLike) { return fs.promises.stat(pathLike); } /** * Create the file {@link filePath} if it doesn't exist, otherwise update the access and modified * time to "now". */ static async touch(filePath) { const dirname = path.dirname(filePath); if (!(await this.exists(dirname))) { await this.mkdir(dirname, { recursive: true }); } // Create the file if it doesn't already exist const file = await fs.promises.open(filePath, 'a'); // Ensure the file's `atime` and `mtime` are updated const date = new Date(); await util.promisify(fs.futimes)(file.fd, date, date); await file.close(); } /** * Return every file in {@param pathLike}, recursively. */ static async walk(pathLike, callback) { let output = []; let entries; try { entries = (await fs.promises.readdir(pathLike, { withFileTypes: true })).filter((entry) => isNotJunk(path.basename(entry.name))); } catch { return []; } const entryIsDirectory = await Promise.all(entries.map(async (entry) => { const fullPath = path.join(pathLike.toString(), entry.name); return (entry.isDirectory() || (entry.isSymbolicLink() && (await this.isDirectory(fullPath)))); })); // Depth-first search directories first const directories = entries .filter((_entry, idx) => entryIsDirectory[idx]) .map((entry) => path.join(pathLike.toString(), entry.name)); for (const directory of directories) { const subDirFiles = await this.walk(directory); if (callback) { callback(subDirFiles.length); } output = [...output, ...subDirFiles]; } const files = entries .filter((_entry, idx) => !entryIsDirectory[idx]) .map((entry) => path.join(pathLike.toString(), entry.name)); if (callback) { callback(files.length); } output = [...output, ...files]; return output; } /** * Write {@param data} to {@param filePath}. */ static async writeFile(filePath, data, options) { const file = await fs.promises.open(filePath, 'w'); await file.writeFile(data, options); await file.sync(); // emulate fs.promises.writeFile() flush:true added in v21.0.0 await file.close(); } } // Assume that all drives we're reading from or writing to were already mounted at startup // https://github.com/cristiammercado/node-disk-info/issues/36 // eslint-disable-next-line @typescript-eslint/explicit-function-return-type FsPoly.DRIVES = (() => { try { return nodeDiskInfo.getDiskInfoSync(); } catch { return []; } })(); export default FsPoly; __decorate([ Memoize(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Array) ], FsPoly, "disksSync", null); //# sourceMappingURL=fsPoly.js.map