UNPKG

@plugjs/plug

Version:
133 lines (114 loc) 5.03 kB
import { assert } from '../asserts' import { Files } from '../files' import { chmod, copyFile, fsConstants, mkdir } from '../fs' import { $p } from '../logging' import { assertAbsolutePath, getAbsoluteParent, resolveAbsolutePath } from '../paths' import { install } from '../pipe' import type { Context, PipeParameters, Plug } from '../pipe' /** Options for copying files */ export interface CopyOptions { /** Whether to allow overwriting or not (default `fail`). */ overwrite?: 'overwrite' | 'fail' | 'skip', /** If specified, use this `mode` (octal string) when creating files. */ mode?: string | number, /** If specified, use this `mode` (octal string) when creating directories. */ dirMode?: string | number, /** If specified, this function will be invoked to rename files. */ rename?: (relative: string) => string } declare module '../index' { export interface Pipe { /** * Copy the curent {@link Files} to a different directory * * @param directory The target directory where files will be copied to */ copy(directory: string): Pipe /** * Copy the curent {@link Files} to a different directory * * @param directory The target directory where files will be copied to * @param options Extra {@link CopyOptions | options} for the copy operation */ copy(directory: string, options: CopyOptions): Pipe } } /* ========================================================================== * * INSTALLATION / IMPLEMENTATION * * ========================================================================== */ /** Copy the curent {@link Files} to a different directory */ install('copy', class Copy implements Plug<Files> { constructor(...args: PipeParameters<'copy'>) constructor( private readonly _directory: string, private readonly _options: CopyOptions = {}, ) {} async pipe(files: Files, context: Context): Promise<Files> { /* Destructure our options with some defaults and compute write flags */ const { mode, dirMode, overwrite = 'fail', rename = (s): string => s } = this._options const flags = overwrite === 'overwrite' ? 0 : fsConstants.COPYFILE_EXCL const dmode = parseMode(dirMode) const fmode = parseMode(mode) /* Our files builder for all written files */ const directory = context.resolve(this._directory) const builder = Files.builder(directory) /* Iterate through all the mappings of the source files */ for (const [ relative, absolute ] of files.pathMappings()) { /* The target absolute is the (possibly) renamed relative source file * relocated to the the target directory */ const target = resolveAbsolutePath(builder.directory, rename(relative)) /* We never copy a file onto itself, but not fail either */ if (target === absolute) { context.log.warn('Cowardly refusing to copy same file', $p(absolute)) continue } /* Create the parent directory, recursively */ const directory = getAbsoluteParent(target) const firstParent = await mkdir(directory, { recursive: true }) /* Set the mode for all created directories */ if (firstParent && (dmode !== undefined)) { assertAbsolutePath(firstParent) for (let dir = directory; ; dir = getAbsoluteParent(dir)) { context.log.trace(`Setting mode ${stringifyMode(dmode)} for directory`, $p(dir)) await chmod(dir, dmode) if (dir === firstParent) break } } context.log.trace(`Copying "${$p(absolute)}" to "${$p(target)}"`) try { /* Actually _copy_ the file */ await copyFile(absolute, target, flags) /* Set the mode, if we need to */ if (fmode !== undefined) { context.log.trace(`Setting mode ${stringifyMode(fmode)} for file`, $p(target)) await chmod(target, fmode) } /* Record this file */ builder.add(target) } catch (error: any) { /* Just log that we skipped the file if we have to */ if ((error.code === 'EEXIST') && (overwrite === 'skip')) { context.log.warn(`Not overwriting existing file ${$p(target)}`) } else { throw error } } } const result = builder.build() context.log.info('Copied', result.length, 'files to', $p(builder.directory)) return result } }) /* ========================================================================== * * INTERNALS * * ========================================================================== */ function parseMode(mode: string | number | undefined): number | undefined { if (mode === undefined) return undefined if (typeof mode === 'number') return mode const parsed = parseInt(mode, 8) assert(! isNaN(parsed), `Invalid mode "${mode}"`) return parsed } function stringifyMode(mode: number): string { return mode.toString(8).padStart(4, '0') }