Skip to content

Commit 02253ee

Browse files
authored
feat: rename single file on copy (#18)
* feat: rename single file on copy
1 parent 5112d35 commit 02253ee

4 files changed

Lines changed: 130 additions & 38 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ Other options include
105105
- `-a` or `--all` which includes files that start with a dot.
106106
- `-F` or `--follow` which follows symbolic links
107107
108+
### Copy and Rename a Single File
109+
110+
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`:
111+
112+
```bash
113+
copyfiles input/.env_publish output/.env
114+
```
115+
116+
This will copy and rename the file in one step.
117+
You can use this for any filename, not just files starting with a dot:
118+
119+
```bash
120+
copyfiles input/original.txt output/renamed.txt
121+
```
122+
123+
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.
124+
108125
### JavaScript API
109126
110127
```js

src/__tests__/cli.spec.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readdir, rmSync, writeFileSync } from 'node:fs';
1+
import { existsSync, readdir, rmSync, writeFileSync } from 'node:fs';
22
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
33

44
import { createDir } from '../index';
@@ -42,28 +42,43 @@ describe('copyfiles', () => {
4242
// Mock process.exit so it doesn't kill the test runner
4343
// @ts-ignore
4444
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => {
45-
// Do nothing
45+
if (code && code !== 0) {
46+
exitSpy.mockRestore();
47+
done(new Error(`process.exit called with code ${code}`));
48+
}
49+
// Do nothing for code 0
4650
});
4751

4852
import('../cli')
4953
.then(() => {
50-
// Wait a tick to ensure file writes are complete
51-
setTimeout(() => {
54+
// Wait until output2/input2 exists, then check files
55+
const start = Date.now();
56+
const check = () => {
57+
if (!existsSync('output2/input2')) {
58+
if (Date.now() - start > 55) {
59+
exitSpy.mockRestore();
60+
return done(new Error('Timeout: output2/input2 was not created'));
61+
}
62+
setTimeout(check, 50);
63+
return;
64+
}
5265
readdir('output2/input2', (err, files) => {
53-
expect(files).toEqual(['a.txt', 'b.txt']);
54-
exitSpy.mockRestore();
55-
done();
66+
try {
67+
expect(err).toBeNull();
68+
expect(files).toEqual(['a.txt', 'b.txt']);
69+
exitSpy.mockRestore();
70+
done();
71+
} catch (e) {
72+
exitSpy.mockRestore();
73+
done(e);
74+
}
5675
});
57-
}, 100); // 100ms delay to allow async file writes
76+
};
77+
check();
5878
})
5979
.catch(e => {
60-
setTimeout(() => {
61-
readdir('output2/input2', (err, files) => {
62-
expect(files).toEqual(['a.txt', 'b.txt']);
63-
exitSpy.mockRestore();
64-
done();
65-
});
66-
}, 100);
80+
exitSpy.mockRestore();
81+
done(e);
6782
});
68-
}))
83+
}), 300);
6984
})

src/__tests__/index.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,34 @@ describe('copyfiles', () => {
317317
});
318318
});
319319
}));
320+
321+
test('copies and renames a single file when destination is a file path', () => new Promise((done: any) => {
322+
writeFileSync('input/.env.production', 'SOME=VALUE');
323+
copyfiles(['input/.env.production', 'output/.env'], {}, (err) => {
324+
expect(err).toBeUndefined();
325+
readdir('output', (err, files) => {
326+
expect(files).toContain('.env');
327+
// Check file contents
328+
const { readFileSync } = require('node:fs');
329+
const content = readFileSync('output/.env', 'utf8');
330+
expect(content).toBe('SOME=VALUE');
331+
done();
332+
});
333+
});
334+
}));
335+
336+
test('copies and renames a single file to a new filename (no dot)', () => new Promise((done: any) => {
337+
writeFileSync('input/original.txt', 'HELLO WORLD');
338+
copyfiles(['input/original.txt', 'output/renamed.txt'], {}, (err) => {
339+
expect(err).toBeUndefined();
340+
readdir('output', (err, files) => {
341+
expect(files).toContain('renamed.txt');
342+
// Check file contents
343+
const { readFileSync } = require('node:fs');
344+
const content = readFileSync('output/renamed.txt', 'utf8');
345+
expect(content).toBe('HELLO WORLD');
346+
done();
347+
});
348+
});
349+
}));
320350
});

src/index.ts

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,26 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
5858

5959
// find file source(s) and destination directory
6060
const sources = paths.slice(0, -1);
61-
let outDir = paths.pop() as string;
62-
outDir = outDir.startsWith('~') ? untildify(outDir) : outDir;
61+
let outPath = paths.pop() as string;
62+
outPath = outPath.startsWith('~') ? untildify(outPath) : outPath;
63+
64+
// Special case: single file to file (rename)
65+
const isSingleFile = sources.length === 1 && !sources[0].includes('*');
66+
let isDestFile = false;
67+
if (isSingleFile) {
68+
try {
69+
const stat = existsSync(outPath) ? require('node:fs').statSync(outPath) : null;
70+
isDestFile = !stat || !stat.isDirectory();
71+
/* v8 ignore next 3 */
72+
} catch {
73+
isDestFile = true;
74+
}
75+
}
6376

64-
// create destination directory if not exists
65-
createDir(outDir);
77+
if (!isDestFile) {
78+
// create destination directory if not exists
79+
createDir(outPath);
80+
}
6681

6782
let globOptions: GlobOptions = {};
6883
if (Array.isArray(options.exclude) && options.exclude.length > 0) {
@@ -101,22 +116,28 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
101116
}
102117

103118
allFiles.forEach((inFile) => {
104-
copyFileStream(inFile, outDir, options, (err) => {
105-
if (hasError) return;
106-
if (err) {
107-
hasError = true;
108-
if (typeof cb === 'function') cb(err);
109-
return;
110-
}
111-
completed++;
112-
if (completed === allFiles.length) {
113-
if (options.verbose || options.stat) {
114-
console.log(`Files copied: ${allFiles.length}`);
115-
console.timeEnd('Execution time');
119+
copyFileStream(
120+
inFile,
121+
outPath,
122+
options,
123+
(err) => {
124+
if (hasError) return;
125+
if (err) {
126+
hasError = true;
127+
if (typeof cb === 'function') cb(err);
128+
return;
116129
}
117-
if (typeof cb === 'function') cb();
118-
}
119-
});
130+
completed++;
131+
if (completed === allFiles.length) {
132+
if (options.verbose || options.stat) {
133+
console.log(`Files copied: ${allFiles.length}`);
134+
console.timeEnd('Execution time');
135+
}
136+
if (typeof cb === 'function') cb();
137+
}
138+
},
139+
isSingleFile && isDestFile // pass as "rename" mode
140+
);
120141
});
121142
}
122143

@@ -127,13 +148,22 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?:
127148
* @param {CopyFileOptions} options
128149
* @param {(e?: Error) => void} cb
129150
*/
130-
function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions, cb: (e?: Error) => void) {
151+
function copyFileStream(
152+
inFile: string,
153+
outDir: string,
154+
options: CopyFileOptions,
155+
cb: (e?: Error) => void,
156+
renameMode = false
157+
) {
131158
const fileDir = dirname(inFile);
132159
const fileName = basename(inFile);
133160
outDir = outDir.startsWith('~') ? untildify(outDir) : outDir;
134161

135162
let dest: string;
136-
if (options.flat || options.up === true) {
163+
if (renameMode) {
164+
dest = outDir;
165+
createDir(path.dirname(dest));
166+
} else if (options.flat || options.up === true) {
137167
dest = join(outDir, fileName);
138168
} else {
139169
const upCount = options.up || 0;
@@ -166,7 +196,7 @@ function copyFileStream(inFile: string, outDir: string, options: CopyFileOptions
166196
readStream.on('error', onceCallback);
167197
writeStream.on('error', onceCallback);
168198
writeStream.on('close', () => {
169-
// Only call callback if not already called by an error
199+
// Only execute callback if not already called by an error
170200
if (!called) {
171201
onceCallback();
172202
}

0 commit comments

Comments
 (0)