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
JavaScript
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