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 @@ [![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)
+> 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;