import * as childProcess from 'child_process' import * as path from 'path' import {promisify} from 'util' import * as ioUtil from './io-util' const exec = promisify(childProcess.exec) /** * Interface for cp/mv options */ export interface CopyOptions { /** Optional. Whether to recursively copy all subdirectories. Defaults to false */ recursive?: boolean /** Optional. Whether to overwrite existing files in the destination. Defaults to true */ force?: boolean } /** * Interface for cp/mv options */ export interface MoveOptions { /** Optional. Whether to overwrite existing files in the destination. Defaults to true */ force?: boolean } /** * Copies a file or folder. * Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js * * @param source source path * @param dest destination path * @param options optional. See CopyOptions. */ export async function cp( source: string, dest: string, options: CopyOptions = {} ): Promise { const {force, recursive} = readCopyOptions(options) const destStat = (await ioUtil.exists(dest)) ? await ioUtil.stat(dest) : null // Dest is an existing file, but not forcing if (destStat && destStat.isFile() && !force) { return } // If dest is an existing directory, should copy inside. const newDest: string = destStat && destStat.isDirectory() ? path.join(dest, path.basename(source)) : dest if (!(await ioUtil.exists(source))) { throw new Error(`no such file or directory: ${source}`) } const sourceStat = await ioUtil.stat(source) if (sourceStat.isDirectory()) { if (!recursive) { throw new Error( `Failed to copy. ${source} is a directory, but tried to copy without recursive flag.` ) } else { await cpDirRecursive(source, newDest, 0, force) } } else { if (path.relative(source, newDest) === '') { // a file cannot be copied to itself throw new Error(`'${newDest}' and '${source}' are the same file`) } await copyFile(source, newDest, force) } } /** * Moves a path. * * @param source source path * @param dest destination path * @param options optional. See MoveOptions. */ export async function mv( source: string, dest: string, options: MoveOptions = {} ): Promise { if (await ioUtil.exists(dest)) { let destExists = true if (await ioUtil.isDirectory(dest)) { // If dest is directory copy src into dest dest = path.join(dest, path.basename(source)) destExists = await ioUtil.exists(dest) } if (destExists) { if (options.force == null || options.force) { await rmRF(dest) } else { throw new Error('Destination already exists') } } } await mkdirP(path.dirname(dest)) await ioUtil.rename(source, dest) } /** * Remove a path recursively with force * * @param inputPath path to remove */ export async function rmRF(inputPath: string): Promise { if (ioUtil.IS_WINDOWS) { // Node doesn't provide a delete operation, only an unlink function. This means that if the file is being used by another // program (e.g. antivirus), it won't be deleted. To address this, we shell out the work to rd/del. try { if (await ioUtil.isDirectory(inputPath, true)) { await exec(`rd /s /q "${inputPath}"`) } else { await exec(`del /f /a "${inputPath}"`) } } catch (err) { // if you try to delete a file that doesn't exist, desired result is achieved // other errors are valid if (err.code !== 'ENOENT') throw err } // Shelling out fails to remove a symlink folder with missing source, this unlink catches that try { await ioUtil.unlink(inputPath) } catch (err) { // if you try to delete a file that doesn't exist, desired result is achieved // other errors are valid if (err.code !== 'ENOENT') throw err } } else { let isDir = false try { isDir = await ioUtil.isDirectory(inputPath) } catch (err) { // if you try to delete a file that doesn't exist, desired result is achieved // other errors are valid if (err.code !== 'ENOENT') throw err return } if (isDir) { await exec(`rm -rf "${inputPath}"`) } else { await ioUtil.unlink(inputPath) } } } /** * Make a directory. Creates the full path with folders in between * Will throw if it fails * * @param fsPath path to create * @returns Promise */ export async function mkdirP(fsPath: string): Promise { await ioUtil.mkdirP(fsPath) } /** * Returns path of a tool had the tool actually been invoked. Resolves via paths. * If you check and the tool does not exist, it will throw. * * @param tool name of the tool * @param check whether to check if tool exists * @returns Promise path to tool */ export async function which(tool: string, check?: boolean): Promise { if (!tool) { throw new Error("parameter 'tool' is required") } // recursive when check=true if (check) { const result: string = await which(tool, false) if (!result) { if (ioUtil.IS_WINDOWS) { throw new Error( `Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.` ) } else { throw new Error( `Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.` ) } } } try { // build the list of extensions to try const extensions: string[] = [] if (ioUtil.IS_WINDOWS && process.env.PATHEXT) { for (const extension of process.env.PATHEXT.split(path.delimiter)) { if (extension) { extensions.push(extension) } } } // if it's rooted, return it if exists. otherwise return empty. if (ioUtil.isRooted(tool)) { const filePath: string = await ioUtil.tryGetExecutablePath( tool, extensions ) if (filePath) { return filePath } return '' } // if any path separators, return empty if (tool.includes('/') || (ioUtil.IS_WINDOWS && tool.includes('\\'))) { return '' } // build the list of directories // // Note, technically "where" checks the current directory on Windows. From a toolkit perspective, // it feels like we should not do this. Checking the current directory seems like more of a use // case of a shell, and the which() function exposed by the toolkit should strive for consistency // across platforms. const directories: string[] = [] if (process.env.PATH) { for (const p of process.env.PATH.split(path.delimiter)) { if (p) { directories.push(p) } } } // return the first match for (const directory of directories) { const filePath = await ioUtil.tryGetExecutablePath( directory + path.sep + tool, extensions ) if (filePath) { return filePath } } return '' } catch (err) { throw new Error(`which failed with message ${err.message}`) } } function readCopyOptions(options: CopyOptions): Required { const force = options.force == null ? true : options.force const recursive = Boolean(options.recursive) return {force, recursive} } async function cpDirRecursive( sourceDir: string, destDir: string, currentDepth: number, force: boolean ): Promise { // Ensure there is not a run away recursive copy if (currentDepth >= 255) return currentDepth++ await mkdirP(destDir) const files: string[] = await ioUtil.readdir(sourceDir) for (const fileName of files) { const srcFile = `${sourceDir}/${fileName}` const destFile = `${destDir}/${fileName}` const srcFileStat = await ioUtil.lstat(srcFile) if (srcFileStat.isDirectory()) { // Recurse await cpDirRecursive(srcFile, destFile, currentDepth, force) } else { await copyFile(srcFile, destFile, force) } } // Change the mode for the newly created directory await ioUtil.chmod(destDir, (await ioUtil.stat(sourceDir)).mode) } // Buffered file copy async function copyFile( srcFile: string, destFile: string, force: boolean ): Promise { if ((await ioUtil.lstat(srcFile)).isSymbolicLink()) { // unlink/re-link it try { await ioUtil.lstat(destFile) await ioUtil.unlink(destFile) } catch (e) { // Try to override file permission if (e.code === 'EPERM') { await ioUtil.chmod(destFile, '0666') await ioUtil.unlink(destFile) } // other errors = it doesn't exist, no work to do } // Copy over symlink const symlinkFull: string = await ioUtil.readlink(srcFile) await ioUtil.symlink( symlinkFull, destFile, ioUtil.IS_WINDOWS ? 'junction' : null ) } else if (!(await ioUtil.exists(destFile)) || force) { await ioUtil.copyFile(srcFile, destFile) } }