UNPKG

cpy

Version:
868 lines (715 loc) 25.1 kB
import process from 'node:process'; import EventEmitter from 'node:events'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import pMap from 'p-map'; import {copyFile} from 'copy-file'; import pFilter from 'p-filter'; import {isDynamicPattern} from 'globby'; import micromatch from 'micromatch'; import CpyError from './cpy-error.js'; import GlobPattern from './glob-pattern.js'; /** @type {import('./index').Options} */ const defaultOptions = { ignoreJunk: true, flat: false, cwd: process.cwd(), update: false, ignoreExisting: false, }; class Entry { /** @param {string} source @param {string} relativePath @param {GlobPattern} pattern */ constructor(source, relativePath, pattern) { /** @type {string} */ this.path = source.split('/').join(path.sep); /** @type {string} */ this.relativePath = relativePath.split('/').join(path.sep); this.pattern = pattern; Object.freeze(this); } get name() { return path.basename(this.path); } get nameWithoutExtension() { return path.basename(this.path, path.extname(this.path)); } get extension() { return path.extname(this.path).slice(1); } } const assertBasename = (name, label, valueLabel = label) => { if (typeof name !== 'string') { throw new TypeError(`${valueLabel} must be a string, got ${typeof name}`); } // Disallow any path separators and traversal if (name.includes('/') || name.includes('\\')) { throw new TypeError(`${label} must not contain path separators`); } if (name === '' || name === '.' || name === '..') { throw new TypeError(`${label} must be a valid filename`); } if (path.basename(name) !== name) { throw new TypeError(`${label} must be a filename, not a path`); } return name; }; const assertRenameBasename = name => assertBasename(name, 'Rename', 'Rename value'); const assertDestinationBasename = (name, label) => assertBasename(name, label); const assertDestinationExtension = extension => { if (typeof extension !== 'string') { throw new TypeError(`Destination extension must be a string, got ${typeof extension}`); } let normalizedExtension = extension; if (normalizedExtension.startsWith('.')) { normalizedExtension = normalizedExtension.slice(1); } if (normalizedExtension.includes('/') || normalizedExtension.includes('\\')) { throw new TypeError('Destination extension must not contain path separators'); } if (normalizedExtension.includes('.')) { throw new TypeError('Destination extension must not include dots'); } return normalizedExtension; }; let hasWarnedAboutLegacyRename = false; const warnAboutLegacyRename = () => { if (hasWarnedAboutLegacyRename) { return; } hasWarnedAboutLegacyRename = true; process.emitWarning('The rename callback with a single basename argument is deprecated and will be removed in the next major release. Use rename(source, destination) instead.', { code: 'CPY_RENAME_DEPRECATED', type: 'DeprecationWarning', }); }; const hasSecondParameter = function_ => { const source = Function.prototype.toString.call(function_).trim(); const arrowIndex = source.indexOf('=>'); const parenIndex = source.indexOf('('); if (parenIndex === -1) { return false; } if (arrowIndex !== -1 && parenIndex > arrowIndex) { return false; } const endIndex = source.indexOf(')', parenIndex + 1); if (endIndex === -1) { return false; } const parameters = source.slice(parenIndex + 1, endIndex); return parameters.includes(','); }; const parseBasename = basename => { const extension = path.extname(basename); return { name: basename, nameWithoutExtension: path.basename(basename, extension), extension: extension.slice(1), }; }; const createSourceFile = filePath => Object.freeze({ path: filePath, ...parseBasename(path.basename(filePath)), }); const createDestinationFile = filePath => { const directory = path.dirname(filePath); const resolvedDirectory = path.resolve(directory); let {name, nameWithoutExtension, extension} = parseBasename(path.basename(filePath)); let resolvedPath = filePath; const updateNameValues = nextName => { ({name, nameWithoutExtension, extension} = parseBasename(nextName)); resolvedPath = path.join(directory, name); }; const updatePath = nextPath => { if (typeof nextPath !== 'string') { throw new TypeError(`Destination path must be a string, got ${typeof nextPath}`); } const resolvedNextPath = path.resolve(directory, nextPath); const nextDirectory = path.dirname(resolvedNextPath); if (nextDirectory !== resolvedDirectory) { throw new TypeError('Destination path must stay within the destination directory'); } const nextName = path.basename(resolvedNextPath); assertDestinationBasename(nextName, 'Destination path'); updateNameValues(nextName); }; const updateName = nextName => { assertDestinationBasename(nextName, 'Destination name'); updateNameValues(nextName); }; const updateNameWithoutExtension = nextNameWithoutExtension => { assertDestinationBasename(nextNameWithoutExtension, 'Destination nameWithoutExtension'); nameWithoutExtension = nextNameWithoutExtension; name = extension === '' ? nameWithoutExtension : `${nameWithoutExtension}.${extension}`; resolvedPath = path.join(directory, name); }; const updateExtension = nextExtension => { extension = assertDestinationExtension(nextExtension); name = extension === '' ? nameWithoutExtension : `${nameWithoutExtension}.${extension}`; resolvedPath = path.join(directory, name); }; const destination = {}; Object.defineProperties(destination, { path: { get() { return resolvedPath; }, set(value) { updatePath(value); }, enumerable: true, }, name: { get() { return name; }, set(value) { updateName(value); }, enumerable: true, }, nameWithoutExtension: { get() { return nameWithoutExtension; }, set(value) { updateNameWithoutExtension(value); }, enumerable: true, }, extension: { get() { return extension; }, set(value) { updateExtension(value); }, enumerable: true, }, }); return destination; }; /** Expand patterns like `'node_modules/{globby,micromatch}'` into `['node_modules/globby', 'node_modules/micromatch']`. @param {string[]} patterns @returns {string[]} */ const expandPatternsWithBraceExpansion = patterns => patterns.flatMap(pattern => ( micromatch.braces(pattern, { expand: true, nodupes: true, }) )); /** @param {string} filePath @param {string} cwd @returns {string} */ const resolveCopyPath = (filePath, cwd) => path.isAbsolute(filePath) ? path.normalize(filePath) : path.resolve(cwd, filePath); const isSelfCopy = (from, to, cwd = process.cwd()) => resolveCopyPath(to, cwd) === resolveCopyPath(from, cwd); const relativizeWithin = (base, file) => { const relativePath = path.relative(base, file); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return; } return relativePath; }; const resolveRelativePath = (bases, file) => { for (const base of bases) { const relativePath = relativizeWithin(base, file); if (relativePath !== undefined) { return relativePath; } } return path.basename(file); }; const resolveCwdRelativePathForGlob = (entry, options) => { const relativeToCwd = relativizeWithin(options.cwd, entry.path); if (relativeToCwd !== undefined) { return relativeToCwd; } const globParent = entry.pattern.normalizedPath; const relativeToGlobParent = relativizeWithin(globParent, entry.path); if (relativeToGlobParent !== undefined) { const globParentBasename = path.basename(globParent); return globParentBasename === '' ? relativeToGlobParent : path.join(globParentBasename, relativeToGlobParent); } return path.basename(entry.path); }; const computeToForGlob = ({entry, destination, options}) => { if (options.flat) { return path.isAbsolute(destination) ? path.join(destination, entry.name) : path.join(options.cwd, destination, entry.name); } // Prefer glob-parent behavior to match existing semantics, // but defend against self-copy / traversal (#114). const from = path.resolve(entry.path); const baseA = entry.pattern.normalizedPath; // Glob parent path const baseB = options.cwd; const relativePath = options.base === 'cwd' ? resolveCwdRelativePathForGlob(entry, options) : resolveRelativePath([baseA, baseB], entry.path); let toPath = path.join(destination, relativePath); // Guard: never copy a file into itself (can truncate under concurrency). if (!options.rename && isSelfCopy(from, toPath, options.cwd)) { const alternativeRelativePath = relativizeWithin(baseB, entry.path); const alternativeToPath = alternativeRelativePath ? path.join(destination, alternativeRelativePath) : path.join(destination, path.basename(entry.path)); toPath = isSelfCopy(from, alternativeToPath, options.cwd) ? path.join(destination, path.basename(entry.path)) : alternativeToPath; } return toPath; }; const computeToForNonGlob = ({entry, destination, options}) => { const baseDestination = path.isAbsolute(destination) ? destination : path.join(options.cwd, destination); if (options.base === 'pattern') { const patternBase = entry.pattern.isDirectory ? path.resolve(options.cwd, entry.pattern.originalPath) : path.resolve(options.cwd, path.dirname(entry.pattern.originalPath)); const relativePath = resolveRelativePath([patternBase], entry.path); return path.join(baseDestination, relativePath); } const relativeToCwd = relativizeWithin(options.cwd, entry.path); const insideCwd = relativeToCwd !== undefined; if (entry.pattern.isDirectory && entry.pattern.symlinkTarget !== undefined) { const relativeToTarget = path.relative(entry.pattern.symlinkTarget, entry.path); if (!relativeToTarget.startsWith('..') && !path.isAbsolute(relativeToTarget)) { const symlinkBase = entry.pattern.symlinkRelative ?? path.basename(entry.pattern.originalPath); return path.join(baseDestination, symlinkBase, relativeToTarget); } } // This check should treat different partitions on Windows as outside the cwd. if (entry.pattern.isDirectory && !insideCwd) { const originalDir = path.resolve(options.cwd, entry.pattern.originalPath); return path.join(baseDestination, path.basename(originalDir), path.relative(originalDir, entry.path)); } if (!entry.pattern.isDirectory && options.flat) { return path.join(baseDestination, path.basename(entry.pattern.originalPath)); } if (relativeToCwd === undefined) { return path.join(baseDestination, entry.name); } return path.join(baseDestination, relativeToCwd); }; const preprocessDestinationPath = ({entry, destination, options}) => ( entry.pattern.hasMagic() ? computeToForGlob({entry, destination, options}) : computeToForNonGlob({entry, destination, options}) ); /** @param {string} source @param {string|Function} rename */ const renameFile = ({source, destination, rename}) => { const directory = path.dirname(destination); if (typeof rename === 'string') { return path.join(directory, assertRenameBasename(rename)); } if (typeof rename === 'function') { const isLegacyRename = rename.length <= 1 && !hasSecondParameter(rename); if (isLegacyRename) { warnAboutLegacyRename(); const filename = path.basename(destination); const result = rename(filename); const isStringObject = result !== null && typeof result === 'object' && Object.prototype.toString.call(result) === '[object String]'; if (typeof result !== 'string' && !isStringObject) { throw new TypeError(`Rename function with a single parameter must return a string, got ${typeof result}. Use rename(source, destination) for the object form.`); } return path.join(directory, assertRenameBasename(String(result))); } const sourceFile = createSourceFile(source); const destinationFile = createDestinationFile(destination); const result = rename(sourceFile, destinationFile); if (result !== undefined) { const isStringObject = result !== null && typeof result === 'object' && Object.prototype.toString.call(result) === '[object String]'; if (typeof result === 'string' || isStringObject) { warnAboutLegacyRename(); return path.join(directory, assertRenameBasename(String(result))); } throw new TypeError(`Rename function must return a string (legacy) or undefined (object form), got ${typeof result}`); } return destinationFile.path; } return destination; }; /** @param {string|string[]} source @param {string} destination @param {import('./index').Options} options */ export default function cpy( source, destination, {concurrency = os.availableParallelism(), ...options} = {}, ) { const copyStatus = new Map(); /** @type {import('events').EventEmitter} */ const progressEmitter = new EventEmitter(); options = { ...defaultOptions, ...options, }; if (options.update !== undefined && typeof options.update !== 'boolean') { throw new TypeError('`update` must be a boolean'); } if (options.ignoreExisting !== undefined && typeof options.ignoreExisting !== 'boolean') { throw new TypeError('`ignoreExisting` must be a boolean'); } if (typeof options.cwd !== 'string') { throw new TypeError('`cwd` must be a string'); } options.cwd = path.resolve(options.cwd); const shouldIgnoreExisting = options.ignoreExisting === true; if (shouldIgnoreExisting) { options.overwrite = false; } const shouldUseUpdate = options.update === true && options.overwrite !== false && !shouldIgnoreExisting; /** @param {GlobPattern[]} patterns @param {string[]} ignorePatterns @returns {Promise<Entry[]>} */ const getEntries = async (patterns, ignorePatterns) => { const entries = []; const resolvedIgnorePatterns = ignorePatterns.map(pattern => resolveCopyPath(pattern, options.cwd)); for (const pattern of patterns) { /** @type {string[]} */ let matches = []; try { matches = pattern.getMatches(); } catch (error) { options.signal?.throwIfAborted(); throw new CpyError(`Cannot glob \`${pattern.originalPath}\`: ${error.message}`, {cause: error}); } const resolvedPatternPath = resolveCopyPath(pattern.originalPath, options.cwd); const isIgnoredByStaticPattern = resolvedIgnorePatterns.some(ignorePattern => relativizeWithin(ignorePattern, resolvedPatternPath) !== undefined); if (matches.length === 0 && !isDynamicPattern(pattern.originalPath) && !isIgnoredByStaticPattern && !isDynamicPattern(ignorePatterns)) { throw new CpyError(`Cannot copy \`${pattern.originalPath}\`: the file doesn't exist`); } for (const sourcePath of matches) { entries.push(new Entry(sourcePath, path.relative(options.cwd, sourcePath), pattern)); } } return entries; }; const promise = (async () => { options.signal?.throwIfAborted(); if (options.base !== undefined && options.base !== 'cwd' && options.base !== 'pattern') { throw new TypeError('`base` must be "cwd" or "pattern"'); } /** @type {Entry[]} */ let entries = []; let completedFiles = 0; let completedSize = 0; const rawPatterns = [source ?? []].flat(); if (rawPatterns.some(pattern => typeof pattern !== 'string')) { throw new TypeError('`source` must be a string or an array of strings'); } if (rawPatterns.includes('')) { throw new CpyError('`source` must not contain empty strings'); } /** @type {GlobPattern[]} */ let patterns = expandPatternsWithBraceExpansion(rawPatterns) .map(string => string.replaceAll('\\', '/')); const sources = patterns.filter(item => !item.startsWith('!')); const ignorePatterns = patterns.filter(item => item.startsWith('!')).map(item => item.slice(1)); if (sources.length === 0 || !destination) { throw new CpyError('`source` and `destination` required'); } patterns = sources.map(pattern => new GlobPattern(pattern, destination, {...options, ignore: ignorePatterns})); entries = await getEntries(patterns, ignorePatterns); const destinationPathByEntry = new Map(); const resolveDestinationPaths = entry => { const cachedPaths = destinationPathByEntry.get(entry); if (cachedPaths) { return cachedPaths; } let destinationPath = preprocessDestinationPath({ entry, destination, options, }); // Apply rename after computing the base destination path destinationPath = renameFile({ source: entry.path, destination: destinationPath, rename: options.rename, }); // Check for self-copy after rename has been applied if (isSelfCopy(entry.path, destinationPath, options.cwd)) { throw new CpyError(`Refusing to copy to itself: \`${entry.path}\``); } const resolvedDestinationPath = resolveCopyPath(destinationPath, options.cwd); const paths = {destinationPath, resolvedDestinationPath}; destinationPathByEntry.set(entry, paths); return paths; }; const createFilterContext = entry => ({ get destinationPath() { return resolveDestinationPaths(entry).resolvedDestinationPath; }, }); if (options.filter !== undefined) { const filterFunction = options.filter; entries = await pFilter(entries, entry => filterFunction(entry, createFilterContext(entry)), { concurrency: 1024, signal: options.signal, }); } if (shouldIgnoreExisting && entries.length > 0) { const destinationPaths = new Set(); const createIgnoreExistingError = (entry, destinationPath, error) => { const reason = error?.message ?? String(error); return new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${destinationPath}\`: ${reason}`, {cause: error}); }; entries = await pFilter(entries, async entry => { const {destinationPath, resolvedDestinationPath} = resolveDestinationPaths(entry); if (destinationPaths.has(resolvedDestinationPath)) { return false; } destinationPaths.add(resolvedDestinationPath); try { await fs.lstat(resolvedDestinationPath); return false; } catch (error) { if (error.code !== 'ENOENT') { throw createIgnoreExistingError(entry, destinationPath, error); } } return true; }, { concurrency: 1024, signal: options.signal, }); } if (shouldUseUpdate && entries.length > 0) { const createUpdateError = (entry, destinationPath, error) => { const reason = error?.message ?? String(error); return new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${destinationPath}\`: ${reason}`, {cause: error}); }; const entryDataList = entries.map((entry, index) => { const {destinationPath, resolvedDestinationPath} = resolveDestinationPaths(entry); return { entry, destinationPath, resolvedDestinationPath, index, }; }); const entryByDestinationPath = new Map(); for (const entryData of entryDataList) { if (!entryByDestinationPath.has(entryData.resolvedDestinationPath)) { entryByDestinationPath.set(entryData.resolvedDestinationPath, entryData.entry); } } const getDestinationState = async destinationPath => { const entry = entryByDestinationPath.get(destinationPath); let stats; try { stats = await fs.stat(destinationPath); } catch (error) { if (error.code === 'ENOENT') { return {exists: false}; } throw createUpdateError(entry, destinationPath, error); } if (!stats.isFile()) { return {nonFile: true}; } return { exists: true, mtimeMs: stats.mtimeMs, size: stats.size, }; }; const destinationPaths = [...new Set(entryDataList.map(({resolvedDestinationPath}) => resolvedDestinationPath))]; const destinationStates = await Promise.all(destinationPaths.map(async destinationPath => [destinationPath, await getDestinationState(destinationPath)])); const destinationStateByPath = new Map(destinationStates); const entriesWithStats = await Promise.all(entryDataList.map(async entryData => { let sourceStats; try { sourceStats = await fs.stat(entryData.entry.path); } catch (error) { throw createUpdateError(entryData.entry, entryData.destinationPath, error); } return {...entryData, sourceStats}; })); const bestCandidateByDestination = new Map(); const registerNonFileCandidate = entryData => { if (!bestCandidateByDestination.has(entryData.resolvedDestinationPath)) { bestCandidateByDestination.set(entryData.resolvedDestinationPath, { entry: entryData.entry, index: entryData.index, mtimeMs: Number.NEGATIVE_INFINITY, }); } }; for (const entryData of entriesWithStats) { const destinationState = destinationStateByPath.get(entryData.resolvedDestinationPath); if (destinationState?.nonFile) { registerNonFileCandidate(entryData); continue; } const shouldCopy = !destinationState?.exists || entryData.sourceStats.mtimeMs > destinationState.mtimeMs || (entryData.sourceStats.mtimeMs === destinationState.mtimeMs && entryData.sourceStats.size !== destinationState.size); if (!shouldCopy) { continue; } const existingCandidate = bestCandidateByDestination.get(entryData.resolvedDestinationPath); const isNewerCandidate = !existingCandidate || entryData.sourceStats.mtimeMs > existingCandidate.mtimeMs || (entryData.sourceStats.mtimeMs === existingCandidate.mtimeMs && entryData.index > existingCandidate.index); if (!isNewerCandidate) { continue; } bestCandidateByDestination.set(entryData.resolvedDestinationPath, { entry: entryData.entry, index: entryData.index, mtimeMs: entryData.sourceStats.mtimeMs, }); } const selectedEntries = new Set([...bestCandidateByDestination.values()].map(candidate => candidate.entry)); entries = entries.filter(entry => selectedEntries.has(entry)); } options.signal?.throwIfAborted(); if (entries.length === 0) { const progressData = { totalFiles: 0, percent: 1, completedFiles: 0, completedSize: 0, sourcePath: '', destinationPath: '', }; if (options.onProgress) { options.onProgress(progressData); } progressEmitter.emit('progress', progressData); } /** @param {import('copy-file').ProgressData} event */ const fileProgressHandler = (statusKey, event) => { const fileStatus = copyStatus.get(statusKey) || { writtenBytes: 0, percent: 0, }; if ( fileStatus.writtenBytes !== event.writtenBytes || fileStatus.percent !== event.percent ) { completedSize -= fileStatus.writtenBytes; completedSize += event.writtenBytes; if (event.percent === 1 && fileStatus.percent !== 1) { completedFiles++; } copyStatus.set(statusKey, { writtenBytes: event.writtenBytes, percent: event.percent, }); const progressData = { totalFiles: entries.length, percent: completedFiles / entries.length, completedFiles, completedSize, sourcePath: event.sourcePath, destinationPath: event.destinationPath, }; if (options.onProgress) { options.onProgress(progressData); } progressEmitter.emit('progress', progressData); } }; const reportSkippedCopy = (statusKey, entry, resolvedDestinationPath) => { fileProgressHandler(statusKey, { writtenBytes: 0, percent: 1, sourcePath: entry.path, destinationPath: resolvedDestinationPath, }); }; const copyFileMapper = async entry => { const {destinationPath, resolvedDestinationPath} = resolveDestinationPaths(entry); if (options.dryRun) { completedFiles++; const progressData = { totalFiles: entries.length, percent: completedFiles / entries.length, completedFiles, completedSize, sourcePath: entry.path, destinationPath: resolvedDestinationPath, }; if (options.onProgress) { options.onProgress(progressData); } progressEmitter.emit('progress', progressData); return resolvedDestinationPath; } const statusKey = `${entry.path}\0${destinationPath}`; try { await copyFile(entry.path, destinationPath, { ...options, onProgress(event) { fileProgressHandler(statusKey, event); }, }); if (options.preserveTimestamps) { options.signal?.throwIfAborted(); const stats = await fs.stat(entry.path); await fs.utimes(destinationPath, stats.atime, stats.mtime); } } catch (error) { options.signal?.throwIfAborted(); if (shouldIgnoreExisting && (error.code === 'EEXIST' || error.code === 'EISDIR')) { reportSkippedCopy(statusKey, entry, resolvedDestinationPath); return; } throw new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${destinationPath}\`: ${error.message}`, {cause: error}); } return destinationPath; }; const results = await pMap(entries, copyFileMapper, { concurrency, signal: options.signal, }); return results.filter(destinationPath => destinationPath !== undefined); })(); promise.on = (_eventName, callback) => { progressEmitter.on(_eventName, callback); return promise; }; return promise; }