const _ = require('lodash');
const {promisify} = require('util');
const _path = require('path');
const _fs = require('fs');
const System = require('./System');
// TODO: replace this with fs.promises when it becomes stable
const fs = {
lstat: promisify(_fs.lstat),
stat: promisify(_fs.stat),
chmod: promisify(_fs.chmod),
realpath: promisify(_fs.realpath),
copyFile: promisify(_fs.copyFile),
appendFile: promisify(_fs.appendFile),
writeFile: promisify(_fs.writeFile),
readFile: promisify(_fs.readFile),
mkdir: promisify(_fs.mkdir),
chown: promisify(_fs.chown),
rename: promisify(_fs.rename),
unlink: promisify(_fs.unlink),
rmdir: promisify(_fs.rmdir),
};
const moduleCache = {};
function getModule(name) {
if (!moduleCache[name]) {
// eslint-disable-next-line global-require, import/no-dynamic-require
moduleCache[name] = promisify(require(name));
}
return moduleCache[name];
}
/**
* File System utilities wrapped in a class
* @class
*/
class File {
/**
* Creates a new File object.
*
* @param {string} path path to the file
*/
constructor(path) {
this.path = path;
}
/**
* Checks whether a file exists already.
*
* @return {boolean} true, if the file exists; false, otherwise
*/
async exists() {
try {
await fs.lstat(this.path);
return true;
}
catch (e) {
return false;
}
}
/**
* Checks whether a file exists already.
*
* @return {boolean} true, if the file exists; false, otherwise
*/
existsSync() {
try {
_fs.lstatSync(this.path);
return true;
}
catch (e) {
return false;
}
}
/**
* Returns whether this File object represents a file.
*
* @return {boolean} true, if this object represents a file; false, otherwise
*/
async isFile() {
try {
return (await fs.lstat(this.path)).isFile();
}
catch (e) {
return false;
}
}
/**
* Returns whether this File object represents a directory.
*
* @return {boolean} true, if this object represents a directory; false, otherwise
*/
async isDir() {
try {
return (await fs.lstat(this.path)).isDirectory();
}
catch (e) {
return false;
}
}
/**
* Returns a Date object representing the time when file was last modified.
*
* @return {Date} Date object, if file exists and its stats are read successfully; 0, otherwise
*/
async mtime() {
try {
return (await fs.lstat(this.path)).mtime;
}
catch (e) {
return 0;
}
}
/**
* Returns a Date object representing the time when file was last changed.
*
* @return {Date} Date object, if file exists and its stats are read successfully; 0, otherwise
*/
async ctime() {
try {
return (await fs.lstat(this.path)).ctime;
}
catch (e) {
return 0;
}
}
/**
* Returns a Date object representing the time when file was last accessed.
*
* @return {Date} Date object, if file exists and its stats are read successfully; 0, otherwise
*/
async atime() {
try {
return (await fs.lstat(this.path)).atime;
}
catch (e) {
return 0;
}
}
/**
* Returns a Date object representing the time when file was created.
*
* @return {Date} Date object, if file exists and its stats are read successfully; 0, otherwise
*/
async crtime() {
try {
return (await fs.lstat(this.path)).birthtime;
}
catch (e) {
return 0;
}
}
/**
* Returns an object with the stats of the file. If the path for the file
* is a symlink, then stats of the symlink are returned.
*
* @return {object} Stats object
*/
async lstat() {
return fs.lstat(this.path);
}
/**
* Returns an object with the stats of the file. If the path for the file
* is a symlink, then stats of the target of the symlink are returned.
*
* @return {object} Stats object
*/
async stat() {
return fs.stat(this.path);
}
/**
* Returns the size of the file in bytes. If the file is not found
* or can't be read successfully, 0 is returned.
*
* @return {number} Size of file (in bytes)
*/
async size() {
try {
return (await fs.lstat(this.path)).size;
}
catch (e) {
return 0;
}
}
/**
* Change the mode of the file.
*
* @param {number|string} mode An octal number or a string representing the file mode
* @return {number} 0, on success; -1, if some error occurred
*/
async chmod(mode) {
return fs.chmod(this.path, mode);
}
/**
* Change the mode of the file or directory recursively.
*
* @param {number|string} mode An octal number or a string representing the file mode
* @return {number} 0, on success; -1, if some error occurred
*/
async chmodr(mode) {
return getModule('chmodr')(this.path, mode);
}
/**
* Change the owner and group of the file.
*
* @param {number|string} user user id, or user name
* @param {number|string} group group id, or group name
* @return {number} 0, on success; any other number, if some error occurred
*
* NOTE: If the owner or group is specified as -1, then that ID is not changed
*/
async chown(user, group) {
if (Number.isInteger(user) && Number.isInteger(group)) {
return fs.chown(this.path, user, group);
}
return System.execOut(`chown ${user}:${group} ${this.path}`);
}
/**
* Change the owner and group of the file recursively.
*
* @param {number|string} user user id, or user name
* @param {number|string} group group id, or group name
* @return {number} 0, on success; any other number, if some error occurred
*
* NOTE: If the owner or group is specified as -1, then that ID is not changed
*/
async chownr(user, group) {
if (Number.isInteger(user) && Number.isInteger(group)) {
return getModule('chownr')(this.path, user, group);
}
return System.execOut(`chown -R ${user}:${group} ${this.path}`);
}
/**
* Change the name or location of the file.
*
* @param {string} newName new path/location (not just name) for the file
* @return {number} 0, on success; -1, if some error occurred
*/
async rename(newName) {
try {
await fs.rename(this.path, newName);
this.path = newName;
}
catch (err) {
return -1;
}
return 0;
}
/**
* Move file to a new location
*
* @param {string} newName new location (or path) for the file
* @return {number} 0, on success; -1, if some error occurred
*/
async mv(newName) {
return this.rename(newName);
}
/**
* Unlink the path from the file.
*
* NOTE: If the path referred to a
* symbolic link, the link is removed. If the path is the only link
* to the file then the file will be deleted.
*/
async unlink() {
return fs.unlink(this.path);
}
/**
* Remove the file.
*
* NOTE: The path is unlinked from the file, but the file
* is deleted only if the path was the only link to the file and
* the file was not opened in any other process.
*/
async rm() {
return this.unlink();
}
/**
* Remove the directory.
*
* NOTE: The directory will be deleted only if it is empty.
*/
async rmdir() {
return fs.rmdir(this.path);
}
/**
* Recursively delete the directory and all its contents.
*/
async rmrf() {
return getModule('rimraf')(this.path);
}
/**
* Create a directory.
*
* @param {number} [mode = 0o755] file mode for the directory
*/
async mkdir(mode = 0o755) {
return fs.mkdir(this.path, mode);
}
/**
* Create a new directory and any necessary subdirectories.
*
* @param {number} [mode = 0o755] file mode for the directory
*/
async mkdirp(mode = 0o755) {
return getModule('mkdirp')(this.path, mode);
}
/**
* Perform a glob search with the path of the file as the pattern.
*
* @return {string[]} Array containing the matches
*/
async glob() {
return getModule('glob')(this.path);
}
/**
* Read contents of the file.
*
* @return {string|Buffer} contents of the file
*/
async read() {
return fs.readFile(this.path, 'utf8');
}
/**
* Create (all necessary directories for) the path of the file/directory.
*
* @param {number} [mode = 0o755] file mode for the directory
*/
async mkdirpPath(mode = 0o755) {
return getModule('mkdirp')(_path.dirname(this.path), mode);
}
/**
* Write contents to the file.
*
* @param {string|Buffer} contents contents to be written to the file
* @param {object} [options = {}] contains options for writing to the file
* The options can include parameters such as fileMode, dirMode, retries and encoding.
*/
async write(contents, options = {}) {
const opts = _.assign({
fileMode: 0o644,
dirMode: 0o755,
retries: 0,
}, options);
if (!opts.retries) {
await this.mkdirpPath(opts.dirMode);
return fs.writeFile(this.path, contents, {encoding: 'utf8', mode: opts.fileMode});
}
try {
return this.write(contents, _.assign(opts, {retries: 0}));
}
catch (e) {
opts.retries--;
return this.write(contents, opts);
}
}
/**
* Append contents to the file.
*
* @param {string|Buffer} contents contents to be written to the file
* @param {object} [options = {}] contains options for appending to the file
*
* The options can include parameters such as fileMode, dirMode, retries and encoding.
*/
async append(contents, options = {}) {
const opts = _.assign({
fileMode: 0o644,
dirMode: 0o755,
retries: 0,
}, options);
if (!opts.retries) {
await this.mkdirpPath(opts.dirMode);
return fs.appendFile(this.path, contents, {encoding: 'utf8', mode: opts.fileMode});
}
try {
return this.append(contents, _.assign(opts, {retries: 0}));
}
catch (e) {
opts.retries--;
return this.append(contents, opts);
}
}
/**
* Copy the file to some destination.
*
* @param {string} destination path of the destination
* @param {object} [options = {}] options for copying the file
*
* If the overwrite option is explicitly set to false, only then
* will the function not attempt to overwrite the file if it (already)
* exists at the destination.
*/
async copy(destination, options = {}) {
const opts = _.assign({
overwrite: true,
}, options);
if (opts.overwrite) {
return fs.copyFile(this.path, destination);
}
return fs.copyFile(this.path, destination, fs.constants.COPYFILE_EXCL);
}
/**
* Return the canonicalized absolute pathname
*
* @return {string} the resolved path
*/
async realpath() {
return fs.realpath(this.path);
}
/**
* Return the canonicalized absolute pathname
*
* @return {string} the resolved path
*/
realpathSync() {
return _fs.realpathSync(this.path);
}
}
/**
* Returns a new File object representing the file located at 'path'.
*
* @param {string} path path of the file
* @return {File} File object representing the file located at the path
*/
function file(path) {
return new File(path);
}
module.exports = file;