#!/usr/bin/env node import { existsSync } from 'node:fs'; import { readdir, readFile, rename, writeFile } from 'node:fs/promises'; import { basename, dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const EXCLUDED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules', 'vendor']); const argumentsList = process.argv.slice(2); const applyChanges = argumentsList.at(-1) === '--apply'; if (applyChanges) { argumentsList.pop(); } if (argumentsList.length !== 2 || argumentsList[0] === '') { console.error('Usage: node replace-text.mjs [--apply]'); process.exit(1); } const [oldText, newText] = argumentsList; if (newText.includes('/') || newText.includes('\\')) { console.error('The new value cannot contain a path separator.'); process.exit(1); } const rootDirectory = resolve(process.cwd()); const scriptPath = resolve(fileURLToPath(import.meta.url)); const textChanges = new Map(); const pathChanges = new Map(); await scanDirectory(rootDirectory); const sortedPathChanges = validatePathChanges(pathChanges); for (const file of textChanges.keys()) { console.log(`${applyChanges ? 'Updating' : 'Would update'} ${displayPath(file)}`); } for (const [source, target] of sortedPathChanges) { console.log(`${applyChanges ? 'Renaming' : 'Would rename'} ${displayPath(source)} to ${displayPath(target)}`); } if (!applyChanges) { console.log('\nDry run only. Re-run with --apply to write the changes.'); process.exit(0); } for (const [file, contents] of textChanges) { await writeFile(file, contents); } for (const [source, target] of sortedPathChanges) { await rename(source, target); } console.log(`Replaced text in ${textChanges.size} file(s) and renamed ${pathChanges.size} path(s).`); /** * Scans a directory tree for text and path replacements. * * @param {string} directory * @returns {Promise} */ async function scanDirectory(directory) { let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch { console.error(`Unable to read directory: ${displayPath(directory)}.`); process.exit(1); } for (const entry of entries) { const path = resolve(directory, entry.name); if (path === scriptPath || entry.isSymbolicLink()) { continue; } collectPathChange(path, oldText, newText, pathChanges); if (entry.isDirectory()) { if (!EXCLUDED_DIRECTORIES.has(entry.name)) { await scanDirectory(path); } continue; } if (entry.isFile()) { await collectTextChange(path, oldText, newText, textChanges); } } } /** * Records a rename when a file or directory basename contains the old text. * * @param {string} path * @param {string} oldValue * @param {string} newValue * @param {Map} changes */ function collectPathChange(path, oldValue, newValue, changes) { if (basename(path).includes(oldValue)) { changes.set(path, resolve(dirname(path), basename(path).replaceAll(oldValue, newValue))); } } /** * Records a replacement when a file is text and contains the old text. * * @param {string} path * @param {string} oldValue * @param {string} newValue * @param {Map} changes * @returns {Promise} */ async function collectTextChange(path, oldValue, newValue, changes) { let contents; try { contents = await readFile(path); } catch { console.error(`Unable to read file: ${displayPath(path)}.`); process.exit(1); } if (!contents.includes(0) && contents.includes(oldValue)) { changes.set(path, contents.toString().replaceAll(oldValue, newValue)); } } /** * Validates path targets and orders renames from deepest to shallowest. * * @param {Map} changes * @returns {[string, string][]} */ function validatePathChanges(changes) { const targets = [...changes.values()]; if (new Set(targets).size !== targets.length) { console.error('The replacement would create duplicate paths.'); process.exit(1); } for (const [source, target] of changes) { if (existsSync(target) && !changes.has(target)) { console.error(`Target path already exists: ${displayPath(target)}.`); process.exit(1); } } return [...changes].sort(([left], [right]) => right.length - left.length); } /** * Formats an absolute path relative to the scanned directory. * * @param {string} path * @returns {string} */ function displayPath(path) { return relative(rootDirectory, path) || '.'; }