#!/usr/bin/env tsx import { Command } from "commander"; import { promises as fs } from "node:fs"; import * as path from "node:path"; async function ensureDir(dir: string): Promise { await fs.mkdir(dir, { recursive: true }); } async function copyDir( src: string, dest: string, includeExts: string[] = [], excludeExts: string[] = [] ): Promise { await ensureDir(dest); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDir(srcPath, destPath, includeExts, excludeExts); } else if (entry.isFile()) { if (!shouldCopyFile(entry.name, includeExts, excludeExts)) { continue; } await fs.copyFile(srcPath, destPath); } } } function parseExtList(list?: string): string[] { if (!list) return []; return list .split(",") .map((s) => s.trim()) .filter(Boolean) .map((e) => e.startsWith(".") ? e.toLowerCase() : `.${e.toLowerCase()}` ); } function isMultiPartPattern(pattern: string): boolean { // true for patterns like '.d.ts' or '.spec.ts' (contains an extra dot after the leading one) return pattern.slice(1).includes("."); } function matchesPattern(fileNameLower: string, pattern: string): boolean { if (isMultiPartPattern(pattern)) { // match by filename suffix for multi-segment patterns return fileNameLower.endsWith(pattern); } // single-segment patterns: compare the file's last extension exactly (prevents '.ts' matching '.tsx') return path.extname(fileNameLower) === pattern; } function shouldCopyFile( fileName: string, includePatterns: string[], excludePatterns: string[] ): boolean { const fileLower = fileName.toLowerCase(); // Exclude takes precedence if ( excludePatterns.length > 0 && excludePatterns.some((p) => matchesPattern(fileLower, p)) ) { return false; } // If no include patterns provided, include everything not excluded if (includePatterns.length === 0) return true; // Otherwise include only if at least one include pattern matches return includePatterns.some((p) => matchesPattern(fileLower, p)); } async function run( sourceArg: string, destArg: string, includeExts: string[] = [], excludeExts: string[] = [] ): Promise<{ src: string; dest: string }> { const src = path.resolve(process.cwd(), sourceArg); const dest = path.resolve(process.cwd(), destArg); // Validate source exists let stat: any; try { stat = await fs.stat(src); } catch { throw new Error(`Source path does not exist: ${src}`); } if (!stat.isDirectory()) { throw new Error(`Source must be a directory: ${src}`); } // Prevent copying into source or a parent of source const rel = path.relative(src, dest); if (!rel || !rel.startsWith("..")) { throw new Error( "Destination must not be inside (or be) the source directory." ); } await ensureDir(dest); await copyDir(src, dest, includeExts, excludeExts); return { src, dest }; } const program = new Command(); program .name("copy-files") .description( "Recursively copy files from a source directory to a destination directory." ) .argument("", "Source directory") .argument("", "Destination directory") .option("-q, --quiet", "Suppress success output") .option( "--include-ext ", "Comma-separated list of file extensions to include, e.g. '.js,.css' or 'js,css'" ) .option( "--exclude-ext ", "Comma-separated list of file extensions to exclude, e.g. '.map,.tmp' or 'map,tmp'" ) .action( async ( sourcePath: string, destinationPath: string, options: { quiet?: boolean; includeExt?: string; excludeExt?: string; } ) => { try { const includeExts = parseExtList(options.includeExt); const excludeExts = parseExtList(options.excludeExt); const result = await run( sourcePath, destinationPath, includeExts, excludeExts ); if (!options.quiet) { console.log( `Copied files from "${result.src}" to "${result.dest}".` ); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } } ); // Show examples in help program.addHelpText( "after", ` Examples: tsx scripts/copyFiles.ts static/fonts dist/fonts tsx scripts/copyFiles.ts assets/images build/images tsx scripts/copyFiles.ts src dist --include-ext .js,.css tsx scripts/copyFiles.ts src dist --exclude-ext map,tmp ` ); program.parseAsync(process.argv);