diff --git a/README.md b/README.md index 6d548ef..ece50ef 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,23 @@ Other options include - `-a` or `--all` which includes files that start with a dot. - `-F` or `--follow` which follows symbolic links +### Copy and Rename a Single File + +You can copy and rename a single file by specifying the source file and the destination filename (not just a directory). For example, to copy `input/.env_publish` to `output/.env`: + +```bash +copyfiles input/.env_publish output/.env +``` + +This will copy and rename the file in one step. +You can use this for any filename, not just files starting with a dot: + +```bash +copyfiles input/original.txt output/renamed.txt +``` + +If the destination path is a directory, the file will be copied into that directory as usual. If the destination path is a filename, the file will be copied and renamed. + ### JavaScript API ```js diff --git a/src/__tests__/cli.spec.ts b/src/__tests__/cli.spec.ts index 28b770a..26e188a 100644 --- a/src/__tests__/cli.spec.ts +++ b/src/__tests__/cli.spec.ts @@ -1,4 +1,4 @@ -import { readdir, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readdir, rmSync, writeFileSync } from 'node:fs'; import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createDir } from '../index'; @@ -42,28 +42,43 @@ describe('copyfiles', () => { // Mock process.exit so it doesn't kill the test runner // @ts-ignore const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { - // Do nothing + if (code && code !== 0) { + exitSpy.mockRestore(); + done(new Error(`process.exit called with code ${code}`)); + } + // Do nothing for code 0 }); import('../cli') .then(() => { - // Wait a tick to ensure file writes are complete - setTimeout(() => { + // Wait until output2/input2 exists, then check files + const start = Date.now(); + const check = () => { + if (!existsSync('output2/input2')) { + if (Date.now() - start > 55) { + exitSpy.mockRestore(); + return done(new Error('Timeout: output2/input2 was not created')); + } + setTimeout(check, 50); + return; + } readdir('output2/input2', (err, files) => { - expect(files).toEqual(['a.txt', 'b.txt']); - exitSpy.mockRestore(); - done(); + try { + expect(err).toBeNull(); + expect(files).toEqual(['a.txt', 'b.txt']); + exitSpy.mockRestore(); + done(); + } catch (e) { + exitSpy.mockRestore(); + done(e); + } }); - }, 100); // 100ms delay to allow async file writes + }; + check(); }) .catch(e => { - setTimeout(() => { - readdir('output2/input2', (err, files) => { - expect(files).toEqual(['a.txt', 'b.txt']); - exitSpy.mockRestore(); - done(); - }); - }, 100); + exitSpy.mockRestore(); + done(e); }); - })) + }), 300); }) \ No newline at end of file diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 4c302f7..5729c2e 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -317,4 +317,34 @@ describe('copyfiles', () => { }); }); })); + + test('copies and renames a single file when destination is a file path', () => new Promise((done: any) => { + writeFileSync('input/.env.production', 'SOME=VALUE'); + copyfiles(['input/.env.production', 'output/.env'], {}, (err) => { + expect(err).toBeUndefined(); + readdir('output', (err, files) => { + expect(files).toContain('.env'); + // Check file contents + const { readFileSync } = require('node:fs'); + const content = readFileSync('output/.env', 'utf8'); + expect(content).toBe('SOME=VALUE'); + done(); + }); + }); + })); + + test('copies and renames a single file to a new filename (no dot)', () => new Promise((done: any) => { + writeFileSync('input/original.txt', 'HELLO WORLD'); + copyfiles(['input/original.txt', 'output/renamed.txt'], {}, (err) => { + expect(err).toBeUndefined(); + readdir('output', (err, files) => { + expect(files).toContain('renamed.txt'); + // Check file contents + const { readFileSync } = require('node:fs'); + const content = readFileSync('output/renamed.txt', 'utf8'); + expect(content).toBe('HELLO WORLD'); + done(); + }); + }); + })); }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 44659b8..f3ec1b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,11 +58,26 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: // find file source(s) and destination directory const sources = paths.slice(0, -1); - let outDir = paths.pop() as string; - outDir = outDir.startsWith('~') ? untildify(outDir) : outDir; + let outPath = paths.pop() as string; + outPath = outPath.startsWith('~') ? untildify(outPath) : outPath; + + // Special case: single file to file (rename) + const isSingleFile = sources.length === 1 && !sources[0].includes('*'); + let isDestFile = false; + if (isSingleFile) { + try { + const stat = existsSync(outPath) ? require('node:fs').statSync(outPath) : null; + isDestFile = !stat || !stat.isDirectory(); + /* v8 ignore next 3 */ + } catch { + isDestFile = true; + } + } - // create destination directory if not exists - createDir(outDir); + if (!isDestFile) { + // create destination directory if not exists + createDir(outPath); + } let globOptions: GlobOptions = {}; if (Array.isArray(options.exclude) && options.exclude.length > 0) { @@ -101,22 +116,28 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: } allFiles.forEach((inFile) => { - copyFileStream(inFile, outDir, options, (err) => { - if (hasError) return; - if (err) { - hasError = true; - if (typeof cb === 'function') cb(err); - return; - } - completed++; - if (completed === allFiles.length) { - if (options.verbose || options.stat) { - console.log(`Files copied: ${allFiles.length}`); - console.timeEnd('Execution time'); + copyFileStream( + inFile, + outPath, + options, + (err) => { + if (hasError) return; + if (err) { + hasError = true; + if (typeof cb === 'function') cb(err); + return; } - if (typeof cb === 'function') cb(); - } - }); + completed++; + if (completed === allFiles.length) { + if (options.verbose || options.stat) { + console.log(`Files copied: ${allFiles.length}`); + console.timeEnd('Execution time'); + } + if (typeof cb === 'function') cb(); + } + }, + isSingleFile && isDestFile // pass as "rename" mode + ); }); } @@ -127,13 +148,22 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: * @param {CopyFileOptions} options * @param {(e?: Error) => void} cb */ -function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions, cb: (e?: Error) => void) { +function copyFileStream( + inFile: string, + outDir: string, + options: CopyFileOptions, + cb: (e?: Error) => void, + renameMode = false +) { const fileDir = dirname(inFile); const fileName = basename(inFile); outDir = outDir.startsWith('~') ? untildify(outDir) : outDir; let dest: string; - if (options.flat || options.up === true) { + if (renameMode) { + dest = outDir; + createDir(path.dirname(dest)); + } else if (options.flat || options.up === true) { dest = join(outDir, fileName); } else { const upCount = options.up || 0; @@ -166,7 +196,7 @@ function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions readStream.on('error', onceCallback); writeStream.on('error', onceCallback); writeStream.on('close', () => { - // Only call callback if not already called by an error + // Only execute callback if not already called by an error if (!called) { onceCallback(); }