Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
"style": {
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useBlockStatements": "error",
"useConsistentCurlyBraces": "error",
"useExponentiationOperator": "off"
},
"nursery": {
Expand Down
64 changes: 44 additions & 20 deletions src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/c.js', 'c');
copyfiles('input/*.txt', 'output', {}, () => {
readdir('output/input', async (_err, files) => {
readdir('output/input', async (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand All @@ -81,7 +81,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/c.js', 'c');
copyfiles('input/*.txt', 'output', undefined, () => {
readdir('output/input', async (_err, files) => {
readdir('output/input', async (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/c.js', 'c');
copyfiles(['input/*.txt'], 'output', {}, () => {
readdir('output/input', (_err, files) => {
readdir('output/input', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
// 'correct mode'
// expect(statSync('output/input/a.txt').mode).toBe(33261);
Expand All @@ -131,7 +131,7 @@ describe('copyfiles', () => {
exclude: ['**/*.js.txt', '**/*.ps.txt'],
},
() => {
readdir('output/input', (_err, files) => {
readdir('output/input', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand All @@ -154,7 +154,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/.c.txt', 'c');
copyfiles(['input/*.txt'], 'output', { all: true }, () => {
readdir('output/input', (_err, files) => {
readdir('output/input', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['.c.txt', 'a.txt', 'b.txt']);
done();
});
Expand All @@ -167,7 +167,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/c.js', 'c');
copyfiles(['input/*.txt'], 'output', { up: 1 }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand All @@ -182,7 +182,7 @@ describe('copyfiles', () => {
writeFileSync('input/c.js', 'c');
writeFileSync('input/deep/d.txt', 'd');
copyfiles('input/**/*.txt', 'output', { up: true }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt', 'd.txt']);
done();
});
Expand All @@ -195,7 +195,7 @@ describe('copyfiles', () => {
writeFileSync('input/other/b.txt', 'b');
writeFileSync('input/other/c.js', 'c');
copyfiles(['input/**/*.txt'], 'output', { up: 2 }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand All @@ -221,7 +221,7 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/other/c.js', 'c');
copyfiles(['input/**/*.txt'], 'output', { flat: true }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
done();
});
Expand All @@ -248,13 +248,32 @@ describe('copyfiles', () => {
const logSpy = vi.spyOn(console, 'log');

copyfiles(['input/**/*'], 'output', { dryRun: true });

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('=== dry-run ==='));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/a.txt → output/input/a.txt'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/other/c.js → output/input/other/c.js'));
expect(existsSync('output/a.txt')).toBe(false);
logSpy.mockRestore();
});

test('dryRun does not copy files but logs actions and has no error in callback', () =>
new Promise((done: any) => {
writeFileSync('input/a.txt', 'a');
writeFileSync('input/other/c.js', 'c');
const logSpy = vi.spyOn(console, 'log');

copyfiles(['input/**/*'], 'output', { dryRun: true }, err => {
expect(err).toBeUndefined();
done();
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('=== dry-run ==='));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/a.txt → output/input/a.txt'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/other/c.js → output/input/other/c.js'));
expect(existsSync('output/a.txt')).toBe(false);
logSpy.mockRestore();
}));

test('dryRun with rename does not copy files but logs actions', () => {
mkdirSync('input/sub');
writeFileSync('input/foo.css', 'foo');
Expand All @@ -277,9 +296,9 @@ describe('copyfiles', () => {
writeFileSync('input/b.txt', 'b');
writeFileSync('input/other/c.js', 'c');
copyfiles('input/**/*.txt', 'output', { flat: true, verbose: true }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
const globCall = logSpy.mock.calls.find(call => call[0] === 'glob found');
const globCall = logSpy.mock.calls.find((call: any[]) => call[0] === 'glob found');
expect(globCall).toBeTruthy();
expect(new Set(globCall![1])).toEqual(new Set(['input/b.txt', 'input/other/a.txt']));
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/other/a.txt', to: 'output/a.txt' });
Expand Down Expand Up @@ -366,18 +385,23 @@ describe('copyfiles', () => {
});

test('sets followSymbolicLinks when options.follow is true', async () => {
if (process.platform === 'win32') return;
if (process.platform === 'win32') {
return;
}
mkdirSync('input/real', { recursive: true });
writeFileSync('input/real/a.txt', 'test');
symlinkSync('real', 'input/link');
await new Promise<void>((resolve, reject) => {
copyfiles('input/link/*.txt', 'output', { follow: true }, err => {
const files = globSync('output/**/*');
console.log('output contents:', files);
const found = files.some(f => f.endsWith('a.txt'));
const found = files.some((f: string) => f.endsWith('a.txt'));
expect(found).toBe(true);
if (err) reject(err);
else resolve();
if (err) {
reject(err);
} else {
resolve();
}
});
});
});
Expand All @@ -389,7 +413,7 @@ describe('copyfiles', () => {
writeFileSync('input/other/b.txt', 'b');
writeFileSync('input/other/c.js', 'c');
copyfiles('input/**/*.txt', 'output', { up: 2, verbose: true }, () => {
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toEqual(['a.txt', 'b.txt']);
expect(logSpy).toHaveBeenCalledWith('glob found', ['input/other/a.txt', 'input/other/b.txt']);
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/other/a.txt', to: 'output/a.txt' });
Expand All @@ -405,7 +429,7 @@ describe('copyfiles', () => {
writeFileSync('input/.env.production', 'SOME=VALUE');
copyfiles('input/.env.production', 'output/.env', {}, err => {
expect(err).toBeUndefined();
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toContain('.env');
// Check file contents
const content = readFileSync('output/.env', 'utf8');
Expand All @@ -420,7 +444,7 @@ describe('copyfiles', () => {
writeFileSync('input/original.txt', 'HELLO WORLD');
copyfiles(['input/original.txt'], 'output/renamed.txt', {}, err => {
expect(err).toBeUndefined();
readdir('output', (_err, files) => {
readdir('output', (_err: Error | null, files: string[]) => {
expect(files).toContain('renamed.txt');
// Check file contents
const content = readFileSync('output/renamed.txt', 'utf8');
Expand Down Expand Up @@ -787,7 +811,7 @@ describe('copyfiles', () => {
writeFileSync('input/bar2.txt', 'bar');
writeFileSync('input/baz3.txt', 'baz');
copyfiles(['input/{foo,bar}*.txt'], 'output', {}, () => {
readdir('output/input', (_err, files) => {
readdir('output/input', (_err: Error | null, files: string[]) => {
expect(files.sort()).toEqual(['bar2.txt', 'foo1.txt']);
done();
});
Expand All @@ -801,7 +825,7 @@ describe('copyfiles', () => {
writeFileSync('input/c.test.js', 'c');
writeFileSync('input/d.js', 'd');
copyfiles(['input/*.js', '!input/*.spec.js', '!input/*.test.js'], 'output', {}, () => {
readdir('output/input', (_err, files) => {
readdir('output/input', (_err: Error | null, files: string[]) => {
expect((files || []).sort()).toEqual(['a.js', 'd.js']);
done();
});
Expand Down
65 changes: 38 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { CopyFileOptions } from './interfaces.js';

export type * from './interfaces.js';

/** Convert an item to an array if it is not already one */
function arrify<T>(item: T | T[]): T[] {
return Array.isArray(item) ? item : [item];
}

/**
* Check if a directory exists, if not then create it
* @param {String} dir - directory to create
Expand Down Expand Up @@ -54,8 +59,7 @@ export function getDestinationPath(inFile: string, outDir: string, options: Copy

// 1. Single file rename (no glob, dest is not a directory, no *)
if (isSingleFileRename && !outDir.includes('*')) {
const dest = outDir;
return callRenameWhenDefined(inFile, dest, options);
return callRenameWhenDefined(inFile, outDir, options);
}

// 2. Wildcard pattern in destination
Expand All @@ -82,12 +86,7 @@ export function getDestinationPath(inFile: string, outDir: string, options: Copy
}

// 3. Flat or up logic (no wildcard)
let baseDir: string;
if (options.flat || upCount === true) {
baseDir = outDir;
} else {
baseDir = join(outDir, dealWith(fileDir, upCount));
}
const baseDir = options.flat || upCount === true ? outDir : join(outDir, dealWith(fileDir, upCount));
const dest = join(baseDir, fileName);

return callRenameWhenDefined(inFile, dest, options);
Expand All @@ -107,7 +106,9 @@ function displayStatWhenEnabled(options: CopyFileOptions, count: number) {
* @param dot - if true, include dotfiles/folders; otherwise, filter them out
*/
export function filterDotFiles(paths: string[], dot: boolean): string[] {
if (dot) return paths;
if (dot) {
return paths;
}
return paths.filter(p => {
// Remove files/dirs starting with a dot after last slash
const base = p.split(/[\\/]/).pop();
Expand Down Expand Up @@ -139,15 +140,16 @@ function getMatchedFiles(
const allFilesSet = new Set<string>();
for (const pattern of sources) {
const isNegated = typeof pattern === 'string' && pattern.startsWith('!');
const adjustedPattern = tryCreatingDir(isNegated ? pattern.slice(1) : pattern, isNegated ? pattern.slice(1) : pattern);
const dirPart = isNegated ? pattern.slice(1) : pattern;
const adjustedPattern = tryCreatingDir(dirPart, dirPart);
let files = globSync(adjustedPattern, { exclude: excludeGlobs }) || [];
if (options.all && adjustedPattern.includes('*') && !adjustedPattern.startsWith('.')) {
const dotPattern = adjustedPattern.replace(/(\*\.[^/]+$|\*$)/, '.$1');
if (dotPattern !== adjustedPattern) {
files = files.concat(globSync(dotPattern, { exclude: excludeGlobs }));
}
}
files = Array.isArray(files) ? files : [files];
files = arrify(files);
files = files.map(f => f.replaceAll('\\', '/'));
files = files.filter(f => !tryCreatingDir(f, false));

Expand Down Expand Up @@ -183,7 +185,7 @@ function getMatchedFiles(
*/
export function copyfiles(sources: string | string[], outPath: string, options: CopyFileOptions = {}, callback?: (e?: Error) => void) {
const cb = callback || options.callback;
sources = Array.isArray(sources) ? sources : [sources];
sources = arrify(sources);

if (options.verbose || options.stat) {
console.time('Execution time');
Expand Down Expand Up @@ -226,12 +228,8 @@ export function copyfiles(sources: string | string[], outPath: string, options:
}

// Set default excludeGlobs only if not provided by user
let excludeGlobs: string[];
if (Array.isArray(options.exclude) && options.exclude.length > 0) {
excludeGlobs = options.exclude;
} else {
excludeGlobs = ['**/.git/**', '**/node_modules/**'];
}
const excludeGlobs =
Array.isArray(options.exclude) && options.exclude.length > 0 ? options.exclude : ['**/.git/**', '**/node_modules/**'];

// Use a Set for deduplication from the start
const allFilesSet = getMatchedFiles(sources, excludeGlobs, isSingleFile, isDestFile, options);
Expand All @@ -242,8 +240,11 @@ export function copyfiles(sources: string | string[], outPath: string, options:

if (options.error && allFilesSet.size < 1) {
const err = new Error('nothing copied');
if (typeof cb === 'function') cb(err);
else throw err;
if (typeof cb === 'function') {
cb(err);
} else {
throw err;
}
return;
}

Expand All @@ -255,7 +256,9 @@ export function copyfiles(sources: string | string[], outPath: string, options:
console.log(`Files copied: 0`);
console.timeEnd('Execution time');
}
if (typeof cb === 'function') cb();
if (typeof cb === 'function') {
cb();
}
return;
}

Expand All @@ -269,7 +272,9 @@ export function copyfiles(sources: string | string[], outPath: string, options:
displayStatWhenEnabled(options, allFilesSet.size);
console.log(head);

if (typeof cb === 'function') cb();
if (typeof cb === 'function') {
cb();
}
return;
}

Expand All @@ -279,16 +284,22 @@ export function copyfiles(sources: string | string[], outPath: string, options:
outPath,
options,
err => {
if (hasError) return;
if (hasError) {
return;
}
if (err) {
hasError = true;
if (typeof cb === 'function') cb(err);
if (typeof cb === 'function') {
cb(err);
}
return;
}
completed++;
if (completed === allFilesSet.size) {
displayStatWhenEnabled(options, allFilesSet.size);
if (typeof cb === 'function') cb();
if (typeof cb === 'function') {
cb();
}
}
},
isSingleFile && isDestFile, // pass as single rename mode
Expand Down Expand Up @@ -324,12 +335,12 @@ function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions
const writeStream = createWriteStream(dest);

let called = false;
function onceCallback(err?: Error) {
const onceCallback = (err?: Error) => {
if (!called) {
called = true;
cb(err);
}
}
};

readStream.on('error', onceCallback);
writeStream.on('error', onceCallback);
Expand Down