hash-runner
Version:
Executes a command when a change is detected in specified files. Not an active file watcher.
265 lines • 11 kB
JavaScript
;
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