#!/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', 'dist', 'target', '__pycache__']); const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); 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)) { return; } const text = decodeUtf8(contents); if (text === null) { console.warn(`Skipped non-UTF-8 file: ${displayPath(path)}.`); return; } changes.set(path, text.replaceAll(oldValue, newValue)); } /** * Decodes a buffer as strict UTF-8, preserving any BOM. * * @param {Buffer} contents * @returns {string|null} Decoded text, or null when the bytes are not valid UTF-8. */ function decodeUtf8(contents) { try { return utf8Decoder.decode(contents); } catch (error) { if (error instanceof TypeError) { return null; } throw error; } } /** * 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) { // Case-insensitive filesystems (NTFS, APFS) resolve a case-only target to the source itself. const isCaseOnlyRename = source.toLowerCase() === target.toLowerCase(); if (existsSync(target) && !changes.has(target) && !isCaseOnlyRename) { 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) || '.'; }