UNPKG

hash-runner

Version:

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

433 lines 18.9 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 () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __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 input files based on the configuration. * @param {string} configDir - Directory containing the configuration. * @param {HashRunnerConfigFile} config - Configuration object. * @param {string} configFilePath - Path to the configuration file to exclude from processing. * @returns {Promise<Record<string, string>>} - A record of file paths and their corresponding hashes. * @private */ async getInputHashes(configDir, config, configFilePath) { const includePatterns = config.inputs.includes; const excludePatterns = [...(config.inputs.excludes || [])]; // Auto-exclude the hash file from the config using glob pattern excludePatterns.push(config.hashFile); // Auto-exclude the config file using glob pattern const configFileName = path.basename(configFilePath); excludePatterns.push(configFileName); 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; } /** * Gets the hashes of output files based on the configuration. * @param {string} configDir - Directory containing the configuration. * @param {HashRunnerConfigFile} config - Configuration object. * @param {string} configFilePath - Path to the configuration file to exclude from processing. * @returns {Promise<Record<string, string> | undefined>} - A record of file paths and their corresponding hashes, or undefined if no outputs configured. * @private */ async getOutputHashes(configDir, config, configFilePath) { if (!config.outputs) { return undefined; } const includePatterns = config.outputs.includes; const excludePatterns = [...(config.outputs.excludes || [])]; // Auto-exclude the hash file from the config using glob pattern excludePatterns.push(config.hashFile); // Auto-exclude the config file using glob pattern const configFileName = path.basename(configFilePath); excludePatterns.push(configFileName); try { 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; } catch (error) { // If we can't read output files (e.g., they don't exist), return undefined // This will be treated as a cache miss debug(`Could not read output files: ${error}`); return undefined; } } /** * Checks if outputs have changed or are missing. * @param {Record<string, string> | undefined} currentOutputs - Current output hashes. * @param {Record<string, string> | undefined} previousOutputs - Previous output hashes. * @returns {boolean} - True if outputs are missing or have changed. * @private */ checkOutputsChanged(currentOutputs, previousOutputs) { // If no outputs are configured, consider them unchanged if (!currentOutputs && !previousOutputs) { return false; } // If outputs are configured but missing, consider them changed if (!currentOutputs && previousOutputs) { debug("Output files are missing, considering cache stale"); return true; } // If outputs were not tracked before but are now configured, consider them changed if (currentOutputs && !previousOutputs) { debug("Output files are newly configured, considering cache stale"); return true; } // Both exist, compare them if (currentOutputs && previousOutputs) { const currentKeys = Object.keys(currentOutputs); const previousKeys = Object.keys(previousOutputs); // Check if number of files changed if (currentKeys.length !== previousKeys.length) { debug(`Output files count changed: ${previousKeys.length} vs ${currentKeys.length}`); return true; } // Check if any hashes changed for (const file of currentKeys) { if (currentOutputs[file] !== previousOutputs[file]) { debug(`Output file hash changed: ${file}`); return true; } } } return false; } /** * Loads the configuration from a file. * @returns {Promise<{ config: HashRunnerConfigFile; configDir: string; configFilePath: string }>} - The configuration, its directory, and file path. * @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"); } const config = result.config; // Check if it's a v3 configuration if ("include" in config || "exclude" in config) { throw new Error("[hash-runner] Detected v3 configuration format. Please see MIGRATING.md for migration instructions."); } // Validate v4 configuration if (!config.inputs || !config.inputs.includes) { throw new Error("[hash-runner] Configuration must have inputs.includes array"); } return { config, configDir: path.dirname(result.filepath), configFilePath: result.filepath }; } /** * Reads the hash file containing previous file hashes. * @param {string} hashFilePath - Path to the hash file. * @returns {Promise<HashFileV2 | null>} - The previous hashes or null if file not found. * @private */ async readHashFile(hashFilePath) { try { const content = await fs.readFile(hashFilePath, "utf8"); const parsed = JSON.parse(content); return this.migrateHashFile(parsed); } catch (_e) { return null; } } /** * Migrates a hash file from v1 to v2 format if needed. * @param {HashFileV1 | HashFileV2} hashData - The hash data to migrate. * @returns {HashFileV2} - The migrated hash data. * @private */ migrateHashFile(hashData) { // Check if it's already v2 format if ("hashSchemaVersion" in hashData && hashData.hashSchemaVersion === "2") { return hashData; } // Migrate from v1 to v2 const v1Data = hashData; debug("Migrating hash file from v1 to v2 format"); return { hashSchemaVersion: "2", inputs: v1Data, outputs: undefined, }; } /** * Writes the provided hash data to a file. * @param {string} hashFilePath - Path to the hash file. * @param {Record<string, string>} inputHashes - The input hash data to write. * @param {Record<string, string>} outputHashes - The output hash data to write. * @returns {Promise<void>} * @private */ async writeHashFile(hashFilePath, inputHashes, outputHashes) { // Create sorted versions of the hash data with alphabetized keys const sortedInputHashes = Object.keys(inputHashes) .sort() .reduce((sorted, key) => { sorted[key] = inputHashes[key]; return sorted; }, {}); const sortedOutputHashes = outputHashes ? Object.keys(outputHashes) .sort() .reduce((sorted, key) => { sorted[key] = outputHashes[key]; return sorted; }, {}) : undefined; const hashFileData = { hashSchemaVersion: "2", inputs: sortedInputHashes, outputs: sortedOutputHashes, }; await fs.writeFile(hashFilePath, JSON.stringify(hashFileData, 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, configFilePath } = 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 [previousHashFile, currentInputHashes, currentOutputHashes] = await Promise.all([ this.readHashFile(hashFilePath), this.getInputHashes(configDir, config, configFilePath), this.getOutputHashes(configDir, config, configFilePath), ]); const previousInputHashes = previousHashFile?.inputs || {}; const previousOutputHashes = previousHashFile?.outputs; debug(`Forced hash regeneration: ${!!this.options.force}`); debug(`Previous hash file exists: ${!!previousHashFile}`); debug(`Previous vs current input hash length: ${Object.keys(previousInputHashes).length} vs ${Object.keys(currentInputHashes).length}`); debug(`Previous vs current output hash length: ${Object.keys(previousOutputHashes || {}).length} vs ${Object.keys(currentOutputHashes || {}).length}`); // Check if we need to run the command const inputsChanged = Object.keys(currentInputHashes).length !== Object.keys(previousInputHashes).length || (await this.checkChangesInChunks(currentInputHashes, previousInputHashes, config.parallelizeComparisonsChunkSize)); const outputsChanged = this.checkOutputsChanged(currentOutputHashes, previousOutputHashes); if (this.options.force || !previousHashFile || inputsChanged || outputsChanged) { if (this.options.force) { this.log("Forced execution. Running command."); } else if (!previousHashFile) { this.log("No previous hash file found. Running command."); } else if (inputsChanged) { this.log("Input changes detected. Running command."); } else if (outputsChanged) { this.log("Output changes detected or outputs missing. Running command."); } this.log(`Running command: "${config.execOnChange}"`); const code = await this.runCommand(config.execOnChange, configDir); // After running the command, re-read output hashes in case they were generated/modified const updatedOutputHashes = await this.getOutputHashes(configDir, config, configFilePath); await this.writeHashFile(hashFilePath, currentInputHashes, updatedOutputHashes); // 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