diff --git a/.vscode/launch.json b/.vscode/launch.json index 582ad02..65f9766 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,10 @@ "test-copyout", "--up", "0", - "--verbose" + "--verbose", + "--dry-run", + "--flat", + "--stat" ] }, { diff --git a/package-lock.json b/package-lock.json index 3f256bf..f82d7b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.2.1", "license": "MIT", "dependencies": { + "cli-nano": "^0.2.1", "tinyglobby": "^0.2.14", - "untildify": "^5.0.0", - "yargs": "^18.0.0" + "untildify": "^5.0.0" }, "bin": { "copyfiles": "dist/cli.js" @@ -1833,6 +1833,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2103,6 +2104,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-nano": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-nano/-/cli-nano-0.2.1.tgz", + "integrity": "sha512-YV8liO5Xp4mVS2GuzpQGs2SFmH6y+6e/0VR4oLhk1cw/mjIfulJMo/iDBHjPCra17bMTbN3cdA7ezntY/1gBzA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -2126,49 +2136,6 @@ "node": ">= 12" } }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2645,6 +2612,7 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, "license": "MIT" }, "node_modules/es-module-lexer": { @@ -2695,15 +2663,6 @@ "@esbuild/win32-x64": "0.25.5" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -2914,19 +2873,11 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4947,6 +4898,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -5010,6 +4962,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -5659,32 +5612,6 @@ "node": ">=8" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -5695,15 +5622,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/yoctocolors-cjs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", diff --git a/package.json b/package.json index 654bf82..94d73ad 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,9 @@ "test:coverage": "vitest --coverage --config ./vitest.config.mts" }, "dependencies": { + "cli-nano": "^0.2.1", "tinyglobby": "^0.2.14", - "untildify": "^5.0.0", - "yargs": "^18.0.0" + "untildify": "^5.0.0" }, "devDependencies": { "@biomejs/biome": "^2.0.5", diff --git a/src/__tests__/cli-fail.spec.ts b/src/__tests__/cli-fail.spec.ts index f23cc28..8fdcff2 100644 --- a/src/__tests__/cli-fail.spec.ts +++ b/src/__tests__/cli-fail.spec.ts @@ -10,13 +10,18 @@ describe('copyfiles', () => { const errorSpy = vi.spyOn(global.console, 'error').mockReturnValue(); const exitSpy = vi.spyOn(process, 'exit'); + vi.spyOn(process, 'argv', 'get').mockReturnValue(['node.exe', 'native-copyfiles/dist/cli.js', 'input1']); + import('../cli.js') .then((cli: any) => { cli(); }) .catch(_ => { + // expect(err.message).toBe('Missing required positional argument: inFile'); + // expect(exitSpy).toHaveBeenCalledWith(1); + // Please make sure to provide both and , i.e.: "copyfiles " expect(errorSpy).toHaveBeenCalledWith( - new Error('Please make sure to provide both and , i.e.: "copyfiles "'), + new Error('Missing required positional argument, i.e.: "copyfiles "'), ); expect(exitSpy).toHaveBeenCalledWith(1); process.exitCode = undefined; diff --git a/src/__tests__/cli-multiple.spec.ts b/src/__tests__/cli-multiple.spec.ts new file mode 100644 index 0000000..0eeb672 --- /dev/null +++ b/src/__tests__/cli-multiple.spec.ts @@ -0,0 +1,85 @@ +import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createDir } from '../index.js'; + +function cleanupFolders() { + try { + rmSync('input1', { recursive: true, force: true }); + rmSync('input2', { recursive: true, force: true }); + rmSync('output2', { recursive: true, force: true }); + } catch (_) {} +} + +describe('copyfiles', () => { + afterEach(() => { + vi.clearAllMocks(); + cleanupFolders(); + process.exitCode = undefined; + }); + + afterAll(() => cleanupFolders()); + + beforeEach(() => { + cleanupFolders(); + createDir('input1/other'); + createDir('input2/other'); + // createDir('output2'); + }); + + test( + 'CLI multiple files', + () => + new Promise((done: any) => { + writeFileSync('input1/a.txt', 'a'); + writeFileSync('input1/b.txt', 'b'); + writeFileSync('input2/a.txt', 'a'); + writeFileSync('input2/b.txt', 'b'); + + vi.spyOn(process, 'argv', 'get').mockReturnValue(['node.exe', 'native-copyfiles/dist/cli.js', 'input1', 'input2', 'output2']); + + // 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) => { + if (code && code !== 0) { + exitSpy.mockRestore(); + done(new Error(`process.exit called with code ${code}`)); + } + // Do nothing for code 0 + }); + + import('../cli.js') + .then(() => { + // 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; + } + try { + setTimeout(() => { + const files = readdirSync('output2/input2'); + expect(files).toEqual(['a.txt', 'b.txt']); + exitSpy.mockRestore(); + done(); + }, 50); + } catch (e) { + exitSpy.mockRestore(); + done(e); + } + }; + check(); + }) + .catch(e => { + exitSpy.mockRestore(); + done(e); + }); + }), + 300, + ); +}); diff --git a/src/__tests__/cli.spec.ts b/src/__tests__/cli.spec.ts index 1c9762f..4a3c6e1 100644 --- a/src/__tests__/cli.spec.ts +++ b/src/__tests__/cli.spec.ts @@ -3,15 +3,16 @@ import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vit import { createDir } from '../index.js'; -async function cleanupFolders() { +function cleanupFolders() { try { + rmSync('input1', { recursive: true, force: true }); rmSync('input2', { recursive: true, force: true }); rmSync('output2', { recursive: true, force: true }); } catch (_) {} } describe('copyfiles', () => { - afterEach(async () => { + afterEach(() => { vi.clearAllMocks(); cleanupFolders(); process.exitCode = undefined; @@ -20,6 +21,7 @@ describe('copyfiles', () => { afterAll(() => cleanupFolders()); beforeEach(() => { + cleanupFolders(); createDir('input2/other'); }); @@ -29,17 +31,18 @@ describe('copyfiles', () => { new Promise((done: any) => { writeFileSync('input2/a.txt', 'a'); writeFileSync('input2/b.txt', 'b'); - writeFileSync('input2/c.js.txt', 'c'); - writeFileSync('input2/d.ps.txt', 'd'); + writeFileSync('input2/c.doc', 'c'); + writeFileSync('input2/d.md', 'd'); - vi.spyOn(process, 'argv', 'get').mockReturnValue([ + vi.spyOn(process, 'argv', 'get').mockReturnValueOnce([ 'node.exe', 'native-copyfiles/dist/cli.js', 'input2', 'output2', '--exclude', - '**/*.js.txt', - '**/*.ps.txt', + '**/*.doc', + '--exclude', + '**/*.md', ]); // Mock process.exit so it doesn't kill the test runner diff --git a/src/cli.ts b/src/cli.ts index 63904d3..52036fd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,76 +1,99 @@ #!/usr/bin/env node -import yargs from 'yargs/yargs'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'cli-nano'; import { copyfiles } from './index.js'; import type { CopyFileOptions } from './interfaces.js'; -const cli = yargs(process.argv.slice(2)); -const argv = cli - .command(' [option]', 'Copy files from a source to a destination directory') - .positional('inFile', { - describe: 'source files', - type: 'string', - }) - .positional('outDirectory', { - describe: 'destination directory', - }) - .option('all', { - alias: 'a', - 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', - description: 'throw error if nothing is copied', - }) - .option('exclude', { - alias: 'e', - type: 'array', - description: 'pattern or glob to exclude (may be passed multiple times)', - }) - .option('flat', { - alias: 'f', - type: 'boolean', - description: 'flatten the output', - }) - .option('follow', { - alias: 'F', - type: 'boolean', - description: 'follow symbolink links', - }) - .option('stat', { - alias: 's', - type: 'boolean', - description: 'show statistics after execution (execution time + file count)', - }) - .option('up', { - alias: 'u', - type: 'number', - description: 'slice a path off the bottom of the paths', - }) - .option('verbose', { - alias: 'V', - type: 'boolean', - description: 'print more information to console', - }) - .help('help') - .alias('help', 'h') - .alias('version', 'v') - .version('0.1.6') - .parse(); +function readPackage() { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const pkgPath = resolve(__dirname, '../package.json'); + const pkg = readFileSync(pkgPath, 'utf8'); + return JSON.parse(pkg); +} -copyfiles((argv as any)._ as string[], argv as CopyFileOptions, err => { +function handleError(err?: Error) { if (err) { console.error(err); process.exit(1); } else { process.exit(0); } -}); +} + +try { + const results = parseArgs({ + command: { + name: 'copyfiles', + description: 'Copy files from a source to a destination directory', + positional: [ + { + name: 'inFile', + description: 'Source files', + type: 'string', + variadic: true, + required: true, + }, + { + name: 'outDirectory', + description: 'Destination directory', + required: true, + }, + ], + }, + options: { + all: { + alias: 'a', + type: 'boolean', + description: 'Include files & directories begining with a dot (.)', + }, + dryRun: { + alias: 'd', + type: 'boolean', + description: 'Show what would be copied, but do not actually copy any files', + }, + error: { + alias: 'E', + type: 'boolean', + description: 'Throw error if nothing is copied', + }, + exclude: { + alias: 'e', + type: 'array', + description: 'Pattern or glob to exclude (may be passed multiple times)', + }, + flat: { + alias: 'f', + type: 'boolean', + description: 'Flatten the output', + }, + follow: { + alias: 'F', + type: 'boolean', + description: 'Follow symbolink links', + }, + stat: { + alias: 's', + type: 'boolean', + description: 'Show statistics after execution (execution time + file count)', + }, + up: { + alias: 'u', + type: 'number', + description: 'Slice a path off the bottom of the paths', + }, + verbose: { + alias: 'V', + type: 'boolean', + description: 'Print more information to console', + }, + }, + version: readPackage().version, + }); + copyfiles([...results.inFile, results.outDirectory], results as CopyFileOptions, err => handleError(err)); +} catch (err) { + handleError(err as Error); +} diff --git a/src/index.ts b/src/index.ts index 0f0779b..c42f3fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import untildify from 'untildify'; import type { CopyFileOptions } from './interfaces.js'; +export type * from './interfaces.js'; + /** * Check if a directory exists, if not then create it * @param {String} dir - directory to create