UNPKG

hash-runner

Version:

Executes a command when a change is detected in specified files. Not an active file watcher.

265 lines 11 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HashRunner = void 0; const node_child_process_1 = require("node:child_process"); const node_crypto_1 = require("node:crypto"); const fs = __importStar(require("node:fs/promises")); const path = __importStar(require("node:path")); const debug_1 = __importDefault(require("debug")); const glob_1 = require("glob"); const lilconfig_1 = require("lilconfig"); const debug = (0, debug_1.default)("hash-runner"); const CI = process.env.CI === "true"; const COMPARISON_CHUNK_SIZE = 100; /** * Class representing a HashRunner that detects file changes and runs a command. */ class HashRunner { configPath; options; /** * Constructs a new HashRunner. * @param {string} [configPath] - Path to the configuration file. * @param {HashRunnerOptions} [options={}] - Options for the HashRunner. */ constructor(configPath, options = {}) { this.configPath = configPath; this.options = options; } /** * Exits the process with a given exit code. * @param {number} code - Exit code. * @private */ exitProcess(code) { if (process.env.IS_TEST) { return; } process.exit(code); } /** * Logs a message to the console if not in silent mode. * @param {string} message - The message to log. * @private */ log(message) { if (!this.options.silent) { console.log(`[hash-runner] ${message}`); } } /** * Runs a given command in a child process. * @param {string} command - The command to run. * @param {string} cwd - The current working directory. * @returns {Promise<number>} - Resolves with the exit code of the command. * @private */ async runCommand(command, cwd) { return new Promise((resolve, reject) => { const child = (0, node_child_process_1.spawn)(command, { cwd, shell: true, stdio: "inherit" }); child.on("close", (code) => { resolve(code ?? 0); }); child.on("error", (error) => { reject(error); }); }); } /** * Computes the hash of a given file using SHA-256. * @param {string} filePath - Path to the file. * @returns {Promise<string>} - The computed hash. * @private */ async computeFileHash(filePath) { debug(`Computing hash for file: "${filePath}"`); const fileBuffer = await fs.readFile(filePath); const hashSum = (0, node_crypto_1.createHash)("sha256"); hashSum.update(fileBuffer); return hashSum.digest("hex"); } /** * Gets the hashes of files included in the configuration. * @param {string} configDir - Directory containing the configuration. * @param {HashRunnerConfigFile} config - Configuration object. * @returns {Promise<Record<string, string>>} - A record of file paths and their corresponding hashes. * @private */ async getHashedFiles(configDir, config) { const includePatterns = config.include || []; const excludePatterns = [...(config.exclude || []), "node_modules/**"]; const includedFiles = await (0, glob_1.glob)(includePatterns, { cwd: configDir, dot: true, absolute: true, ignore: excludePatterns, nodir: true, }); const fileHashes = {}; await Promise.all(includedFiles.map(async (file) => { const relativePath = path.relative(configDir, file); fileHashes[relativePath] = await this.computeFileHash(file); })); return fileHashes; } /** * Loads the configuration from a file. * @returns {Promise<{ config: HashRunnerConfigFile; configDir: string }>} - The configuration and its directory. * @throws {Error} - Throws an error if the config file is not found or is empty. * @private */ async loadConfig() { const explorer = (0, lilconfig_1.lilconfig)("hash-runner"); let result; if (this.configPath) { result = await explorer.load(this.configPath); } else { result = await explorer.search(); } if (!result || result.isEmpty) { throw new Error("[hash-runner] Config file not found or is empty"); } return { config: result.config, configDir: path.dirname(result.filepath) }; } /** * Reads the hash file containing previous file hashes. * @param {string} hashFilePath - Path to the hash file. * @returns {Promise<Record<string, string> | null>} - The previous hashes or null if file not found. * @private */ async readHashFile(hashFilePath) { try { const content = await fs.readFile(hashFilePath, "utf8"); return JSON.parse(content); } catch (e) { return null; } } /** * Writes the provided hash data to a file. * @param {string} hashFilePath - Path to the hash file. * @param {Record<string, string>} hashData - The hash data to write. * @returns {Promise<void>} * @private */ async writeHashFile(hashFilePath, hashData) { await fs.writeFile(hashFilePath, JSON.stringify(hashData, null, 2)); } /** * Checks if there are changes between current and previous file hashes in chunks. * @param {Record<string, string>} currentHashes - The current file hashes. * @param {Record<string, string>} previousHashes - The previous file hashes. * @param {number} [chunkSize=COMPARISON_CHUNK_SIZE] - Chunk size for parallel comparisons. * @returns {Promise<boolean>} - Resolves to true if changes are detected, otherwise false. * @private */ async checkChangesInChunks(currentHashes, previousHashes, chunkSize = COMPARISON_CHUNK_SIZE) { const fileKeys = Object.keys(currentHashes); const numCursors = Math.ceil(fileKeys.length / chunkSize); const abortController = new AbortController(); /** * Checks a chunk of files for hash mismatches. * @param {number} startIndex - Start index of the chunk. * @param {number} endIndex - End index of the chunk. * @param {AbortSignal} signal - Abort signal to halt the operation if changes are detected. * @returns {Promise<void>} */ async function checkChunk(startIndex, endIndex, signal) { for (let i = startIndex; i < endIndex; i++) { if (signal.aborted) return; // Return immediately if the operation is aborted const file = fileKeys[i]; if (currentHashes[file] !== previousHashes[file]) { debug(`Hash mismatch detected for file: "${file} (${currentHashes[file]} vs ${previousHashes[file]})"`); abortController.abort(); // Abort other operations if a change is detected return; } } } // Set the length of the array to numCursors const promises = Array.from({ length: numCursors }, (_, cursor) => { // Calculate the starting index of the current chunk const startIndex = cursor * chunkSize; // Calculate the ending index of the current chunk. // Ensure it does not exceed the total number of file keys. const endIndex = Math.min(startIndex + chunkSize, fileKeys.length); // Call the checkChunk function with the start and end indices of the current chunk // and the abort signal to handle early termination. return checkChunk(startIndex, endIndex, abortController.signal); }); try { debug(`Comparing hashes in ${numCursors} chunks of ${chunkSize} files each`); await Promise.all(promises); } catch (err) { if (err?.name !== "AbortError") { throw err; } } return abortController.signal.aborted; } /** * Main function to run the hash runner. * @returns {Promise<void>} */ async run() { const { config, configDir } = await this.loadConfig(); const hashFilePath = path.join(configDir, config.hashFile); if (CI) { this.log("CI environment detected. Bypassing hash check."); const code = await this.runCommand(config.execOnChange, configDir); this.exitProcess(code); return; } const [previousHashes, currentHashes] = await Promise.all([ this.readHashFile(hashFilePath), this.getHashedFiles(configDir, config), ]); debug(`Forced hash regeneration: ${!!this.options.force}`); debug(`Previous hashes exist: ${!!previousHashes}`); debug(`Previous vs current hash length: ${Object.keys(previousHashes || {}).length} vs ${Object.keys(currentHashes).length}`); if (this.options.force || !previousHashes || Object.keys(currentHashes).length !== Object.keys(previousHashes).length || (await this.checkChangesInChunks(currentHashes, previousHashes, config.parallelizeComparisonsChunkSize))) { this.log(`Changes detected. Running command: "${config.execOnChange}"`); const code = await this.runCommand(config.execOnChange, configDir); await this.writeHashFile(hashFilePath, currentHashes); // Exit the process with the command's exit code this.exitProcess(code); return; } // If no changes are detected, log and exit this.log("No changes detected. Exiting."); } } exports.HashRunner = HashRunner; //# sourceMappingURL=index.js.map