diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..b41fef0
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["biomejs.biome"]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..de18fb4
--- /dev/null
+++ b/.vscode/settings.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 56f11b1..7175d0b 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,13 @@
[](https://www.npmjs.com/package/native-copyfiles)
[](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)
+> 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).
> This mean calling: `copyfiles source target [options]` instead of `copyfiles [options] source target`
### Install
@@ -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]
@@ -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)
diff --git a/package.json b/package.json
index 6b28c60..edddb1a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts
index 2b6ba65..8289cf8 100644
--- a/src/__tests__/index.spec.ts
+++ b/src/__tests__/index.spec.ts
@@ -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';
@@ -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();
diff --git a/src/cli.ts b/src/cli.ts
index 183ce3b..63904d3 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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',
diff --git a/src/index.ts b/src/index.ts
index c18a920..82724cd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
@@ -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
*/
@@ -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
@@ -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));
}
@@ -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,
@@ -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();
}
},
@@ -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;
@@ -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) {
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 1d203a7..771a06e 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -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;