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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 31 additions & 16 deletions src/__tests__/cli.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
})
30 changes: 30 additions & 0 deletions src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
}));
});
74 changes: 52 additions & 22 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
);
});
}

Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down