UNPKG

@appium/support

Version:

Support libs used across Appium packages

454 lines (422 loc) 13.7 kB
import B from 'bluebird'; import crypto from 'node:crypto'; import { close, constants, createReadStream, createWriteStream, promises as fsPromises, read, write, rmSync, open, type PathLike, type MakeDirectoryOptions, type ReadAsyncOptions, type Stats, } from 'node:fs'; import {promisify} from 'node:util'; import {glob} from 'glob'; import type {GlobOptions} from 'glob'; import klaw from 'klaw'; import type {Walker} from 'klaw'; import _ from 'lodash'; import ncp from 'ncp'; import {packageDirectorySync} from 'package-directory'; import path from 'node:path'; import {readPackageSync, type NormalizeOptions, type NormalizedPackageJson} from 'read-pkg'; import sanitize from 'sanitize-filename'; import which from 'which'; import log from './logger'; import {Timer} from './timing'; import {isWindows} from './system'; import {pluralize} from './util'; const ncpAsync = promisify(ncp) as ( source: string, dest: string, opts?: ncp.Options ) => Promise<void>; const findRootCached = _.memoize( packageDirectorySync, (opts: {cwd?: string} | undefined) => opts?.cwd ); /** Options for {@linkcode fs.mv} */ export interface MvOptions { /** Whether to automatically create the destination folder structure */ mkdirp?: boolean; /** Set to false to throw if the destination file already exists */ clobber?: boolean; /** @deprecated Legacy, not used */ limit?: number; } /** * Callback used during directory walking in {@linkcode fs.walkDir}. * Return true to stop walking. */ export type WalkDirCallback = ( itemPath: string, isDirectory: boolean ) => boolean | void | Promise<boolean | void>; /** * Promisified fs.read signature. * @template TBuffer - Buffer type (e.g. NodeJS.ArrayBufferView) * @deprecated use `typeof read.__promisify__` instead */ export type ReadFn<TBuffer extends NodeJS.ArrayBufferView = NodeJS.ArrayBufferView> = ( fd: number, buffer: TBuffer | ReadAsyncOptions<TBuffer>, offset?: number, length?: number, position?: number | null ) => B<{bytesRead: number; buffer: TBuffer}>; function isErrnoException(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && 'code' in err; } export const fs = { /** * Resolves `true` if `path` is _readable_. * On Windows, ACLs are not supported, so this becomes a simple check for existence. * This function will never reject. */ async hasAccess(filePath: PathLike): Promise<boolean> { try { await fsPromises.access(filePath, constants.R_OK); } catch { return false; } return true; }, /** * Resolves `true` if `path` is executable; `false` otherwise. * On Windows, delegates to {@linkcode fs.hasAccess}. * This function will never reject. */ async isExecutable(filePath: PathLike): Promise<boolean> { try { if (isWindows()) { return await fs.hasAccess(filePath); } await fsPromises.access(filePath, constants.R_OK | constants.X_OK); } catch { return false; } return true; }, /** Alias for {@linkcode fs.hasAccess} */ async exists(filePath: PathLike): Promise<boolean> { return await fs.hasAccess(filePath); }, /** * Remove a directory and all its contents, recursively. * @see https://nodejs.org/api/fs.html#fspromisesrmpath-options */ async rimraf(filepath: PathLike): Promise<void> { return await fsPromises.rm(filepath, {recursive: true, force: true}); }, /** * Remove a directory and all its contents, recursively (sync). * @see https://nodejs.org/api/fs.html#fsrmsyncpath-options */ rimrafSync(filepath: PathLike): void { return rmSync(filepath, {recursive: true, force: true}); }, /** * Like Node.js `fsPromises.mkdir()`, but will not reject if the directory already exists. * @see https://nodejs.org/api/fs.html#fspromisesmkdirpath-options */ async mkdir( filepath: string | Buffer | URL, opts: MakeDirectoryOptions = {} ): Promise<string | undefined> { try { return await fsPromises.mkdir(filepath, opts); } catch (err) { if (isErrnoException(err) && err.code !== 'EEXIST') { throw err; } } }, /** * Copies files and entire directories. * @see https://npm.im/ncp */ async copyFile( source: string, destination: string, opts: ncp.Options = {} ): Promise<void> { if (!(await fs.hasAccess(source))) { throw new Error(`The file at '${source}' does not exist or is not accessible`); } return await ncpAsync(source, destination, opts); }, /** Create an MD5 hash of a file. */ async md5(filePath: PathLike): Promise<string> { return await fs.hash(filePath, 'md5'); }, /** * Move a file or a folder. */ async mv( from: string, to: string, opts: MvOptions = {} ): Promise<void> { const ensureDestination = async (p: PathLike): Promise<boolean> => { if (opts?.mkdirp && !(await this.exists(p))) { await fsPromises.mkdir(p, {recursive: true}); return true; } return false; }; const renameFile = async ( src: PathLike, dst: PathLike, skipExistenceCheck: boolean ): Promise<void> => { if (!skipExistenceCheck && (await this.exists(dst))) { if (opts?.clobber === false) { const err = new Error(`The destination path '${dst}' already exists`) as NodeJS.ErrnoException; err.code = 'EEXIST'; throw err; } await this.rimraf(dst); } try { await fsPromises.rename(src, dst); } catch (err) { if (isErrnoException(err) && err.code === 'EXDEV') { await this.copyFile(String(src), String(dst)); await this.rimraf(src); } else { throw err; } } }; let fromStat: Stats; try { fromStat = await fsPromises.stat(from); } catch (err) { if (isErrnoException(err) && err.code === 'ENOENT') { throw new Error(`The source path '${from}' does not exist or is not accessible`); } throw err; } if (fromStat.isFile()) { const dstRootWasCreated = await ensureDestination(path.dirname(to)); await renameFile(from, to, dstRootWasCreated); } else if (fromStat.isDirectory()) { const dstRootWasCreated = await ensureDestination(to); const items = await fsPromises.readdir(from, {withFileTypes: true}); for (const item of items) { const srcPath = path.join(from, item.name); const destPath = path.join(to, item.name); if (item.isDirectory()) { await this.mv(srcPath, destPath, opts); } else if (item.isFile()) { await renameFile(srcPath, destPath, dstRootWasCreated); } } } else { return; } await this.rimraf(from); }, /** Find path to an executable in system `PATH`. @see https://github.com/npm/node-which */ which, /** * Given a glob pattern, resolve with list of files matching that pattern. * @see https://github.com/isaacs/node-glob */ glob(pattern: string, options?: GlobOptions): Promise<string[]> { return Promise.resolve( (options ? glob(pattern, options) : glob(pattern)) as Promise<string[]> ); }, /** Sanitize a filename. @see https://github.com/parshap/node-sanitize-filename */ sanitizeName: sanitize, /** Create a hex digest of some file at `filePath`. */ async hash(filePath: PathLike, algorithm: string = 'sha1'): Promise<string> { return await new Promise<string>((resolve, reject) => { const fileHash = crypto.createHash(algorithm); const readStream = createReadStream(filePath); readStream.on('error', (e: Error) => reject( new Error( `Cannot calculate ${algorithm} hash for '${filePath}'. Original error: ${e.message}` ) ) ); readStream.on('data', (chunk: Buffer | string) => fileHash.update(chunk)); readStream.on('end', () => resolve(fileHash.digest('hex'))); }); }, /** * Returns a Walker instance (readable stream / async iterator). * @see https://www.npmjs.com/package/klaw */ walk(dir: string, opts?: klaw.Options): Walker { return klaw(dir, opts); }, /** Recursively create a directory. */ async mkdirp(dir: PathLike): Promise<string | undefined> { return await fs.mkdir(dir, {recursive: true}); }, /** * Walks a directory; callback is invoked with path joined with dir. * @param dir - Directory path to start walking * @param recursive - If true, walk subdirectories * @param callback - Called for each path; return true to stop * @returns The found path or null if not found */ /* eslint-disable promise/prefer-await-to-callbacks -- walkDir uses callback + stream .on() + Promise executor */ async walkDir( dir: string, recursive: boolean, callback: WalkDirCallback ): Promise<string | null> { let isValidRoot = false; let errMsg: string | null = null; try { isValidRoot = (await fs.stat(dir)).isDirectory(); } catch (e) { errMsg = e instanceof Error ? e.message : String(e); } if (!isValidRoot) { throw new Error( `'${dir}' is not a valid root directory` + (errMsg ? `. Original error: ${errMsg}` : '') ); } let walker: Walker | undefined; let fileCount = 0; let directoryCount = 0; const timer = new Timer().start(); return await new Promise<string | null>(function (resolve, reject) { let lastFileProcessed: Promise<string | undefined> = Promise.resolve(undefined); walker = klaw(dir, { depthLimit: recursive ? -1 : 0, }); walker .on('data', function (item: klaw.Item) { if (walker) { walker.pause(); } if (!item.stats.isDirectory()) { fileCount++; } else { directoryCount++; } lastFileProcessed = (async () => { try { const done = await callback(item.path, item.stats.isDirectory()); if (done) { resolve(item.path); return item.path; } if (walker) { walker.resume(); } } catch (err) { reject(err); } })(); }) .on('error', function (err: Error, item?: {path: string}) { log.warn(`Got an error while walking '${item?.path ?? 'unknown'}': ${err.message}`); if (isErrnoException(err) && err.code === 'ENOENT') { log.warn('All files may not have been accessed'); reject(err); } }) .on('end', function () { (async () => { try { const file = await lastFileProcessed; resolve(file ?? null); } catch (err) { log.warn(`Unexpected error: ${err instanceof Error ? err.message : err}`); reject(err); } })(); }); }).finally(function () { log.debug( `Traversed ${pluralize('directory', directoryCount, true)} ` + `and ${pluralize('file', fileCount, true)} ` + `in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` ); if (walker) { walker.destroy(); } }); /* eslint-enable promise/prefer-await-to-callbacks */ }, /** * Reads the closest `package.json` from absolute path `dir`. * @throws If there were problems finding or reading `package.json` */ readPackageJsonFrom( dir: string, opts: NormalizeOptions & {cwd?: string} = {} ): NormalizedPackageJson { const cwd = fs.findRoot(dir); try { return readPackageSync({normalize: true, ...opts, cwd}); } catch (err) { const message = err instanceof Error ? err.message : String(err); (err as Error).message = `Failed to read a \`package.json\` from dir \`${dir}\`:\n\n${message}`; throw err; } }, /** * Finds the project root directory from `dir`. * @throws TypeError If `dir` is not a non-empty absolute path * @throws Error If project root could not be found */ findRoot(dir: string): string { if (!dir || !path.isAbsolute(dir)) { throw new TypeError('`findRoot()` must be provided a non-empty, absolute path'); } const result = findRootCached({cwd: dir}); if (!result) { throw new Error(`\`findRoot()\` could not find \`package.json\` from ${dir}`); } return result; }, access: fsPromises.access, appendFile: fsPromises.appendFile, chmod: fsPromises.chmod, close: promisify(close), constants, createWriteStream, createReadStream, lstat: fsPromises.lstat, /** * Promisified fs.open. Resolves with a file descriptor (not FileHandle). * Use fs.openFile for a FileHandle. */ open: promisify(open), openFile: fsPromises.open, readdir: fsPromises.readdir, read: promisify(read), readFile: fsPromises.readFile, readlink: fsPromises.readlink, realpath: fsPromises.realpath, rename: fsPromises.rename, stat: fsPromises.stat, symlink: fsPromises.symlink, unlink: fsPromises.unlink, // TODO: replace with native promisify in Appium 4 write: B.promisify(write), writeFile: fsPromises.writeFile, /** @deprecated Use `constants.F_OK` instead. */ F_OK: constants.F_OK, /** @deprecated Use `constants.R_OK` instead. */ R_OK: constants.R_OK, /** @deprecated Use `constants.W_OK` instead. */ W_OK: constants.W_OK, /** @deprecated Use `constants.X_OK` instead. */ X_OK: constants.X_OK, }; export default fs;