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
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome"]
}
11 changes: 11 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": false,
"editor.codeActionsOnSave":{
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@
[![npm](https://img.shields.io/npm/dy/native-copyfiles)](https://www.npmjs.com/package/native-copyfiles)
[![npm bundle size](https://img.shields.io/bundlephobia/minzip/native-copyfiles?color=success&label=gzip)](https://bundlephobia.com/result?p=native-copyfiles)

## Copyfiles
#### native-copyfiles
## native-copyfiles

Copy files easily via JavaScript or the CLI, it uses [tinyglobby](https://www.npmjs.com/package/tinyglobby) internally for glob patterns and [yargs](https://www.npmjs.com/package/yargs) for the CLI.

The library is very similar to the [copyfiles](https://www.npmjs.com/package/copyfiles) package, it is however written with more native NodeJS code and less dependencies (3 instead of 7). The package options are the same (except for `--soft` which is not implemented), some new options were also added in this project here (see below).
The library is very similar from the outside to the [copyfiles](https://www.npmjs.com/package/copyfiles) package, however its internal is quite different, it uses more native NodeJS code and less dependencies (3 instead of 7). The package options are the same (except for `--soft` which is not implemented), some new options were also added in this project here (see below).

> Note: there is 1 major difference with `copyfiles`, any options must be provided as a suffix after the source/target directories command (the original project had them as prefix)<br>
> Note: there is 1 noticeable difference with `copyfiles`, any options must be provided as a suffix after the source/target directories command (the original project had them as prefix).<br>
> This mean calling: `copyfiles source target [options]` instead of `copyfiles [options] source target`

### Install
Expand All @@ -28,16 +27,17 @@ npm install native-copyfiles -D
Usage: copyfiles inFile [more files ...] outDirectory [options]

Options:
-u, --up slice a path off the bottom of the paths [number]
-a, --all include files & directories begining with a dot (.) [boolean]
-f, --flat flatten the output [boolean]
-e, --exclude pattern or glob to exclude (may be passed multiple times) [string|string[]]
-E, --error throw error if nothing is copied [boolean]
-V, --verbose print more information to console [boolean]
-F, --follow follow symbolic links [boolean]
-s, --stat show statistics after execution (time + file count) [boolean]
-v, --version show version number [boolean]
-h, --help show help [boolean]
-u, --up slice a path off the bottom of the paths [number]
-a, --all include files & directories begining with a dot (.) [boolean]
-d, --dry-run show what would be copied, without actually copying anything [boolean]
-f, --flat flatten the output [boolean]
-e, --exclude pattern or glob to exclude (may be passed multiple times) [string|string[]]
-E, --error throw error if nothing is copied [boolean]
-V, --verbose print more information to console [boolean]
-F, --follow follow symbolic links [boolean]
-s, --stat show statistics after execution (time + file count) [boolean]
-v, --version show version number [boolean]
-h, --help show help [boolean]
```

> [!NOTE]
Expand Down Expand Up @@ -202,6 +202,7 @@ and finally the third and last argument is a callback function which is executed
up: number, // slice a path off the bottom of the paths
exclude: string, // exclude pattern
all: bool, // include dot files
dryRun: bool, // show what would be copied, without actually copying anything
follow: bool, // follow symlinked directories when expanding ** patterns
error: bool // raise errors if no files copied
stat: bool // show statistics after execution (time + file count)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"biome:lint:write": "biome lint --write ./src",
"biome:format:check": "biome format ./src",
"biome:format:write": "biome format --write ./src",
"preview:copy": "node dist/cli.js test-copyin test-copyout --flat --verbose",
"preview:copy": "node dist/cli.js test-copyin test-copyout --dry-run --flat --stat",
"preview:release": "release-it --only-version --dry-run",
"release": "release-it --only-version",
"test": "vitest --watch --config ./vitest.config.mts",
Expand Down
30 changes: 29 additions & 1 deletion src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, readdir, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readdir, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { Readable } from 'node:stream';
import { globSync } from 'tinyglobby';
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
Expand Down Expand Up @@ -215,6 +215,34 @@ describe('copyfiles', () => {
}
});

test('dryRun does not copy files but logs actions', () => {
writeFileSync('input/a.txt', 'a');
writeFileSync('input/other/c.js', 'c');
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 with rename does not copy files but logs actions', () => {
createDir('input/sub');
writeFileSync('input/foo.css', 'foo');
writeFileSync('input/sub/bar.css', 'bar');
const logSpy = vi.spyOn(console, 'log');

copyfiles(['input/**/*.css', 'output/*.scss'], { dryRun: true, stat: true });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('=== dry-run ==='));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/foo.css → output/input/foo.scss'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('copy: input/sub/bar.css → output/input/sub/bar.scss'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Files copied: 2'));
expect(existsSync('output/a.txt')).toBe(false);
logSpy.mockRestore();
});

test('verbose flat', () =>
new Promise((done: any) => {
const logSpy = vi.spyOn(global.console, 'log').mockReturnValue();
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const argv = cli
type: 'boolean',
description: 'include files & directories begining with a dot (.)',
})
.option('dryRun', {
alias: 'd',
type: 'boolean',
description: 'Show what would be copied, but do not actually copy any files',
})
.option('error', {
alias: 'E',
type: 'boolean',
Expand Down
46 changes: 36 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createReadStream, createWriteStream, existsSync, mkdirSync, statSync } from 'node:fs';
import { basename, dirname, extname, join, normalize, posix, sep } from 'node:path';
import untildify from 'untildify';
import { type GlobOptions, globSync } from 'tinyglobby';
import untildify from 'untildify';

import type { CopyFileOptions } from './interfaces.js';

Expand All @@ -15,6 +15,15 @@ export function createDir(dir: string) {
}
}

/**
* Converts a path from any platform to posix
* @param {String} pathStr - the path to convert
* @returns {String} - the converted posix path
*/
export function convertToPosix(pathStr: string) {
return pathStr.replaceAll(sep, posix.sep);
}

/**
* Helper to throw or callback with error
*/
Expand Down Expand Up @@ -84,6 +93,15 @@ function getDestinationPath(inFile: string, outDir: string, options: CopyFileOpt
return callRenameWhenDefined(inFile, dest, options);
}

/** Show statistics when `verbose` and/or `stat` are enabled */
function displayStatWhenEnabled(options: CopyFileOptions, count: number) {
if (options.verbose || options.stat) {
console.log('\n');
console.log(`Files copied: ${count}`);
console.timeEnd('Execution time');
}
}

/**
* Copy the files per a glob pattern, the first item(s) can be a 1 or more files to copy
* while the last item in the array is the output outDirectory directory
Expand Down Expand Up @@ -134,8 +152,8 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
}
}

// create destination directory if not exists
if (!isDestFile) {
// create destination directory if not exists
createDir(dirname(outPath));
}

Expand Down Expand Up @@ -175,6 +193,20 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
return;
}

if (options.dryRun) {
const head = '=== dry-run ===';
console.log(head);
allFiles.forEach(inFile => {
const dest = getDestinationPath(inFile, outPath, options, isSingleFile && isDestFile);
console.log(`copy: ${convertToPosix(inFile)} → ${convertToPosix(dest)}`);
});
displayStatWhenEnabled(options, allFiles.length);
console.log(head);

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

allFiles.forEach(inFile => {
copyFileStream(
inFile,
Expand All @@ -189,10 +221,7 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
}
completed++;
if (completed === allFiles.length) {
if (options.verbose || options.stat) {
console.log(`Files copied: ${allFiles.length}`);
console.timeEnd('Execution time');
}
displayStatWhenEnabled(options, allFiles.length);
if (typeof cb === 'function') cb();
}
},
Expand All @@ -207,6 +236,7 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
* @param {String} outDir
* @param {CopyFileOptions} options
* @param {(e?: Error) => void} cb
* @param {Boolean} isSingleFileRename - whether the operation is a single file rename (no glob, dest is not a directory, no *)
*/
function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions, cb: (e?: Error) => void, isSingleFileRename = false) {
outDir = outDir.startsWith('~') ? untildify(outDir) : outDir;
Expand Down Expand Up @@ -245,10 +275,6 @@ function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions
});

readStream.pipe(writeStream);

function convertToPosix(pathStr: string) {
return pathStr.replaceAll(sep, posix.sep);
}
}

function depth(str: string) {
Expand Down
3 changes: 3 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export interface CopyFileOptions {
/** Include files & directories beginning with a dot (.) */
all?: boolean;

/** Show what would be copied, but do not actually copy any files */
dryRun?: boolean;

/** Throw error if nothing is copied */
error?: boolean;

Expand Down