src/FileUtil.js
import archiver from 'archiver';
import fs from 'fs-extra';
import glob from 'glob';
import isGlob from 'is-glob';
import path from 'path';
/**
* FileUtil - Provides several utility methods for archiving, copying, reading, and writing files.
*/
export default class FileUtil
{
/**
* Instantiate FileUtil.
*
* @param {FileUtilOptions} options - FileUtilOptions to set.
*/
constructor(options = {})
{
if (typeof options !== 'object') { throw new TypeError(`'options' is not an object.`); }
/**
* Stores FileUtil options.
* @type {FileUtilOptions}
* @private
*/
this._options =
{
compressFormat: 'tar.gz',
eventbus: null,
lockRelative: false,
logEvent: 'log:info:raw',
relativePath: null
};
/**
* Stores the stack of archiver instances.
* @type {Array}
*/
this.archiverStack = [];
/**
* Provides a unique counter for temporary archives.
* @type {number}
*/
this.archiveCntr = 0;
this.setOptions(options);
}
/**
* Create a compressed archive relative to the output destination. All subsequent file write and copy operations
* will add to the existing archive. You must invoke `archiveFinalize` to complete the archive process.
*
* @param {string} destPath - Destination path and file name; the compress format extension will be appended.
*
* @param {boolean} [addToParent=true] - If a parent archiver exists then add child archive to it and delete local
* file.
*
* @param {boolean} [silent=false] - When true `output: <destPath>` is logged.
*/
archiveCreate(destPath, addToParent = true, silent = false)
{
if (typeof destPath !== 'string') { throw new TypeError(`'destPath' is not a 'string'.`); }
if (typeof addToParent !== 'boolean') { throw new TypeError(`'addToParent' is not a 'boolean'.`); }
if (typeof silent !== 'boolean') { throw new TypeError(`'silent' is not a 'boolean'.`); }
if (typeof silent === 'boolean' && !silent) { s_LOG(this._options, `creating archive: ${destPath}`); }
const compressFormat = this._options.compressFormat;
// Add archive format to `destPath`.
destPath = `${destPath}.${compressFormat}`;
let resolvedDest = this._options.relativePath ? path.resolve(this._options.relativePath, destPath) :
path.resolve(destPath);
// If a child archive is being created, `addToParent` is false then change the resolved destination to a
// temporary file so that the parent instance can add it before finalizing.
if (this.archiverStack.length > 0 && addToParent)
{
const dirName = path.dirname(resolvedDest);
resolvedDest = `${dirName}${path.sep}.temp-${this.archiveCntr++}`;
}
let archive;
switch (compressFormat)
{
case 'tar.gz':
archive = archiver('tar', { gzip: true, gzipOptions: { level: 9 } });
break;
case 'zip':
archive = archiver('zip', { zlib: { level: 9 } });
break;
default:
throw new Error(`Unknown compression format: '${compressFormat}'.`);
}
// Make sure the resolved destination is a valid directory; if not create it...
fs.ensureDirSync(path.dirname(resolvedDest));
const stream = fs.createWriteStream(resolvedDest);
// Catch any archiver errors.
archive.on('error', (err) => { throw err; });
// Pipe archive data to the file.
archive.pipe(stream);
// Create an archive instance holding relevant data for tracking children archives.
const instance =
{
archive,
destPath,
resolvedDest,
stream,
addToParent,
childPromises: []
};
this.archiverStack.push(instance);
}
/**
* Finalizes an active archive. You must first invoke `archiveCreate`.
*
* @param {boolean} [silent=false] - When true `output: <destPath>` is logged.
*
* @returns {Promise} - A resolved promise is returned which is triggered once archive finalization completes.
*/
archiveFinalize(silent = false)
{
if (typeof silent !== 'boolean') { throw new TypeError(`'silent' is not a 'boolean'.`); }
const instance = this._popArchive();
if (instance !== null)
{
const parentInstance = this._getArchive();
// If `addToParent` is true and there is a parent instance then push a new Promise into the parents
// `childPromises` array and add callbacks to the current instances file stream to resolve the Promise.
if (instance.addToParent && parentInstance !== null)
{
parentInstance.childPromises.push(new Promise((resolve, reject) =>
{
// Add event callbacks to instance stream such that on close the Promise is resolved.
instance.stream.on('close', () =>
{
resolve({ resolvedDest: instance.resolvedDest, destPath: instance.destPath });
});
// Any errors will reject the promise.
instance.stream.on('error', reject);
}));
}
if (typeof silent === 'boolean' && !silent)
{
s_LOG(this._options, `finalizing archive: ${instance.destPath}`);
}
// Resolve any child promises before finalizing current instance.
return Promise.all(instance.childPromises).then((results) =>
{
// There are temporary child archives to insert into the current instance.
for (const result of results)
{
// Append temporary archive to requested relative destPath.
instance.archive.append(fs.createReadStream(result.resolvedDest), { name: result.destPath });
// Remove temporary archive.
fs.removeSync(result.resolvedDest);
}
// finalize the archive (ie we are done appending files but streams have to finish yet)
instance.archive.finalize();
});
}
else
{
s_LOG(this._options, `No active archive to finalize.`);
}
return Promise.resolve();
}
/**
* Copy a source path / to destination path or relative path.
*
* @param {string} srcPath - Source path.
*
* @param {string} destPath - Destination path.
*
* @param {boolean} [silent=false] - When true `output: <destPath>` is logged.
*/
copy(srcPath, destPath, silent = false)
{
if (typeof srcPath !== 'string') { throw new TypeError(`'srcPath' is not a 'string'.`); }
if (typeof destPath !== 'string') { throw new TypeError(`'destPath' is not a 'string'.`); }
if (typeof silent !== 'boolean') { throw new TypeError(`'silent' is not a 'boolean'.`); }
if (typeof silent === 'boolean' && !silent) { s_LOG(this._options, `output: ${destPath}`); }
const instance = this._getArchive();
if (instance !== null)
{
if (fs.statSync(srcPath).isDirectory())
{
instance.archive.directory(srcPath, destPath);
}
else
{
instance.archive.file(srcPath, { name: destPath });
}
}
else
{
fs.copySync(srcPath, this._options.relativePath ? path.resolve(this._options.relativePath, destPath) :
path.resolve(destPath));
}
}
/**
* Gets the current archiver instance.
*
* @returns {*}
*/
_getArchive()
{
return this.archiverStack.length > 0 ? this.archiverStack[this.archiverStack.length - 1] : null;
}
/**
* Returns a copy of the FileUtil options.
*
* @returns {FileUtilOptions} - FileUtil options.
*/
getOptions()
{
return JSON.parse(JSON.stringify(this._options));
}
/**
* Hydrates a list of files finally defined as globs. Bare directory paths will be converted to globs.
*
* @param {string|Array<string>} globs - A string or array of strings defining file globs. Any entry which is not
* a glob will be converted to an all inclusive glob.
*
* @returns {{files: Array<string>, globs: Array<string>}}
*/
hydrateGlob(globs)
{
if (!Array.isArray(globs) && typeof globs !== 'string')
{
throw new TypeError(`'globs' is not a 'string' or an 'array'.`);
}
// If not an array then convert globEntry to an array.
const globArray = Array.isArray(globs) ? globs : [globs];
// Verify that all entries are strings.
for (let cntr = 0; cntr < globArray.length; cntr++)
{
if (typeof globArray[cntr] !== 'string')
{
throw new TypeError(`'globs[${cntr}]: '${globArray[cntr]}' is not a 'string'.`);
}
}
const actualGlobs = [];
// Process glob array and if any entry is not a glob then convert it to an all inclusive glob.
let files = [].concat(...globArray.map((entry) =>
{
// Convert raw file path to glob as necessary.
if (!isGlob(entry))
{
// Determine if any included trailing path separator is included.
const results = (/([\\/])$/).exec(entry);
const pathSep = results !== null ? results[0] : path.sep;
// Build all inclusive glob based on bare path and covert it into an array containing it.
entry = entry.endsWith(pathSep) ? `${entry}**${pathSep}*` : `${entry}${pathSep}**${pathSep}*`;
}
// Store all glob entries to catch any ones converted to globs above.
actualGlobs.push(entry);
return glob.sync(path.resolve(entry));
}));
// Filter out non-files; IE directories
files = files.filter((file) => fs.statSync(file).isFile());
return { files, globs: actualGlobs };
}
/**
* Adds event bindings for FileUtil via `typhonjs-plugin-manager`.
*
* @param {PluginEvent} ev - A plugin event.
*/
onPluginLoad(ev)
{
const eventbus = ev.eventbus;
this._options.eventbus = eventbus;
let eventPrepend = 'typhonjs:';
const options = ev.pluginOptions;
// Apply any plugin options.
if (typeof options === 'object')
{
this.setOptions(options);
// If `eventPrepend` is defined then it is prepended before all event bindings.
if (typeof options.eventPrepend === 'string') { eventPrepend = `${options.eventPrepend}:`; }
}
eventbus.on(`${eventPrepend}util:file:hydrate:glob`, this.hydrateGlob, this);
eventbus.on(`${eventPrepend}util:file:archive:create`, this.archiveCreate, this);
eventbus.on(`${eventPrepend}util:file:archive:finalize`, this.archiveFinalize, this);
eventbus.on(`${eventPrepend}util:file:copy`, this.copy, this);
eventbus.on(`${eventPrepend}util:file:get:options`, this.getOptions, this);
eventbus.on(`${eventPrepend}util:file:read:lines`, this.readLines, this);
eventbus.on(`${eventPrepend}util:file:set:options`, this.setOptions, this);
eventbus.on(`${eventPrepend}util:file:write`, this.writeFile, this);
}
/**
* Pops an archiver instance off the stack.
*
* @returns {*}
*/
_popArchive()
{
return this.archiverStack.length > 0 ? this.archiverStack.pop() : null;
}
/**
* Read lines from a file given a start and end line number.
*
* @param {string} filePath - The file path to load.
*
* @param {number} lineStart - The start line
*
* @param {number} lineEnd - The end line
*
* @returns {String[]}
*/
readLines(filePath, lineStart, lineEnd)
{
if (typeof filePath !== 'string') { throw new TypeError(`'filePath' is not a 'string'.`); }
if (typeof lineStart !== 'number') { throw new TypeError(`'lineStart' is not a 'number'.`); }
if (typeof lineEnd !== 'number') { throw new TypeError(`'lineEnd' is not a 'number'.`); }
const lines = fs.readFileSync(filePath).toString().split('\n');
const targetLines = [];
if (lineStart < 0) { lineStart = 0; }
if (lineEnd > lines.length) { lineEnd = lines.length; }
for (let cntr = lineStart; cntr < lineEnd; cntr++)
{
targetLines.push(`${cntr + 1}| ${lines[cntr]}`);
}
return targetLines;
}
/**
* Set optional parameters.
*
* @param {FileUtilOptions} options - Defines optional parameters to set.
*/
setOptions(options = {})
{
if (typeof options !== 'object') { throw new TypeError(`'options' is not an 'object'.`); }
if (!this._options.lockRelative && typeof options.relativePath === 'string')
{
this._options.relativePath = options.relativePath;
}
// Only set `lockRelative` if it already has not been set to true.
if (!this._options.lockRelative && typeof options.lockRelative === 'boolean')
{
this._options.lockRelative = options.lockRelative;
}
if (typeof options.compressFormat === 'string') { this._options.compressFormat = options.compressFormat; }
if (typeof options.eventbus === 'object') { this._options.eventbus = options.eventbus; }
if (typeof options.logEvent === 'string') { this._options.logEvent = options.logEvent; }
}
/**
* Write a file to file path or relative path.
*
* @param {object} fileData - The file data.
*
* @param {string} filePath - A relative file path and name to `config.destination`.
*
* @param {boolean} [silent=false] - When true `output: <destPath>` is logged.
*
* @param {string} [encoding=utf8] - The encoding type.
*/
writeFile(fileData, filePath, silent = false, encoding = 'utf8')
{
if (typeof filePath !== 'string') { throw new TypeError(`'filePath' is not a 'string'.`); }
if (typeof silent !== 'boolean') { throw new TypeError(`'silent' is not a 'boolean'.`); }
if (typeof encoding !== 'string') { throw new TypeError(`'encoding' is not a 'string'.`); }
if (typeof fileData === 'undefined' || fileData === null)
{
throw new TypeError(`'filePath' is not a 'string'.`);
}
if (typeof silent === 'boolean' && !silent) { s_LOG(this._options, `output: ${filePath}`); }
const instance = this._getArchive();
if (instance !== null)
{
instance.archive.append(fileData, { name: filePath });
}
else
{
// If this._options.relativePath is defined then resolve the relative path against filePath.
fs.outputFileSync(this._options.relativePath ? path.resolve(this._options.relativePath, filePath) : filePath,
fileData, { encoding });
}
}
}
/**
* Creates an instance of FileUtil and assigns several methods to the plugin eventbus.
*
* @param {PluginEvent} ev - A plugin event.
*
* @ignore
*/
export function onPluginLoad(ev)
{
new FileUtil().onPluginLoad(ev);
}
// Module private ---------------------------------------------------------------------------------------------------
/**
* Helper method to log a message over an eventbus if one is defined.
*
* @param {FileUtilOptions} options - FileUtil options.
*
* @param {*} message - A message to log.
*
* @ignore
*/
const s_LOG = (options, message) =>
{
if (options.eventbus && options.logEvent) { options.eventbus.trigger(options.logEvent, message); }
};