hash-runner
Version:
Executes a command when a change is detected in specified files. Not an active file watcher.
433 lines • 18.9 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 () {
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