@factorialco/shadowdog
Version:
<img src="https://raw.githubusercontent.com/factorialco/shadowdog/refs/heads/main/logo.png" alt="drawing" width="100"/>
259 lines (258 loc) • 10.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 });
// fs-extra is used for ensureDir but we're not using it directly in this file
const fs_1 = require("fs");
const path = __importStar(require("path"));
const crypto = __importStar(require("crypto"));
const glob_1 = require("glob");
const chalk_1 = __importDefault(require("chalk"));
const utils_1 = require("../utils");
// Global state
let lockFilePath = '';
let config = null;
let writePromise = null;
let isInGenerateMode = false;
// Track execution times per artifact (keyed by artifact output)
const artifactExecutionTimes = new Map();
const artifactStartTimes = new Map();
// Function to detect and resolve merge conflicts in lock file
const detectAndResolveConflicts = (filePath) => {
try {
const content = (0, fs_1.readFileSync)(filePath, 'utf8');
// Check for common merge conflict markers
if (content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')) {
(0, utils_1.logMessage)(`🔧 Detected merge conflicts in lock file. Regenerating from scratch...`);
return true;
}
// Check if the file is valid JSON
try {
JSON.parse(content);
}
catch {
(0, utils_1.logMessage)(`🔧 Lock file contains invalid JSON. Regenerating from scratch...`);
return true;
}
return false;
}
catch {
// File doesn't exist or can't be read, that's fine
return false;
}
};
// Helper functions
// Compute SHA256 hash of artifact content
const computeArtifactContentSha = (artifactPath) => {
try {
const fullPath = path.join(process.cwd(), artifactPath);
const stats = (0, fs_1.statSync)(fullPath);
if (stats.isDirectory()) {
// For directories, create a hash based on all file contents and structure
const files = (0, glob_1.sync)('**/*', { cwd: fullPath, nodir: true }).sort();
const hash = crypto.createHash('sha256');
hash.update(`directory:${artifactPath}`);
for (const file of files) {
const filePath = path.join(fullPath, file);
hash.update(`file:${file}`);
try {
const content = (0, fs_1.readFileSync)(filePath);
hash.update(content);
}
catch {
// Skip files that can't be read
hash.update('unreadable');
}
}
return hash.digest('hex').slice(0, 10);
}
else {
// For files, hash the content directly
const content = (0, fs_1.readFileSync)(fullPath);
const hash = crypto.createHash('sha256');
hash.update(content);
return hash.digest('hex').slice(0, 10);
}
}
catch {
// If artifact doesn't exist or can't be read, return a placeholder
return 'not-found';
}
};
const createArtifactEntry = (artifact, files, environment, command) => {
var _a;
// Capture actual environment variable values (obfuscated for security)
const environmentValues = {};
environment.forEach((envVar) => {
var _a;
const value = (_a = process.env[envVar]) !== null && _a !== void 0 ? _a : '';
// Obfuscate values to prevent leaking tokens/secrets
const obfuscatedValue = value.length > 0
? `${value.slice(0, 2)}${'*'.repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`
: '';
environmentValues[envVar] = obfuscatedValue;
});
// Use the same cache computation as the local cache plugin
const currentCache = (0, utils_1.computeCache)(files, environment, command);
const artifactCacheIdentifier = (0, utils_1.computeFileCacheName)(currentCache, artifact.output);
// Compute SHA of the artifact content
const outputSha = computeArtifactContentSha(artifact.output);
// Get execution time for this artifact (default to 0 if not tracked)
const executionTime = (_a = artifactExecutionTimes.get(artifact.output)) !== null && _a !== void 0 ? _a : 0;
return {
output: artifact.output,
outputSha,
cacheIdentifier: artifactCacheIdentifier,
executionTime,
fileManifest: {
watchedFilesCount: files.length,
watchedFiles: files,
environment: environmentValues,
command,
},
};
};
const regenerateLockFile = async () => {
if (!config) {
return;
}
// Wait for any existing write operation to complete
if (writePromise) {
await writePromise;
}
// Create new write promise to prevent race conditions
writePromise = (async () => {
const start = Date.now();
// Initialize lock file path if not already done
if (!lockFilePath) {
lockFilePath = path.resolve(process.cwd(), 'shadowdog-lock.json');
}
// Note: Conflict detection is already done in configLoaded event
// This function will regenerate the lock file regardless
// Generate all artifacts in deterministic order based on shadowdog.json
const allArtifacts = [];
for (const watcherConfig of config.watchers) {
// Process files with ignores
const processedFiles = (0, utils_1.processFiles)(watcherConfig.files, [
...(watcherConfig.ignored || []),
...config.defaultIgnoredFiles,
]);
for (const commandConfig of watcherConfig.commands) {
for (const artifact of commandConfig.artifacts) {
const artifactEntry = createArtifactEntry(artifact, processedFiles, watcherConfig.environment, commandConfig.command);
allArtifacts.push(artifactEntry);
}
}
}
const lockFile = {
version: (0, utils_1.readShadowdogVersion)(),
nodeVersion: process.version,
artifacts: allArtifacts,
};
// Skip directory creation since the file already exists
// await fs.ensureDir(path.dirname(lockFilePath))
try {
// Use synchronous write to avoid hanging issues
const jsonContent = JSON.stringify(lockFile, null, 2);
(0, fs_1.writeFileSync)(lockFilePath, jsonContent, 'utf8');
const seconds = ((Date.now() - start) / 1000).toFixed(2);
const relativeLockPath = path.relative(process.cwd(), lockFilePath);
const artifactCount = allArtifacts.length;
const artifactNames = allArtifacts.map((a) => a.output).join(', ');
(0, utils_1.logMessage)(`📝 Lock file regenerated at '${chalk_1.default.blue(relativeLockPath)}' with ${chalk_1.default.blue(artifactCount)} artifacts: ${chalk_1.default.blue(artifactNames)} ${chalk_1.default.cyan(`(${seconds}s)`)}`);
}
catch (error) {
(0, utils_1.logMessage)(`❌ Failed to write lock file: ${error.message}`);
}
})();
await writePromise;
};
// Event listener plugin implementation
const listener = (eventEmitter) => {
// Store config reference when it's loaded
eventEmitter.on('configLoaded', ({ config: loadedConfig }) => {
config = loadedConfig;
// Initialize lock file path and check for conflicts early
if (!lockFilePath) {
lockFilePath = path.resolve(process.cwd(), 'shadowdog-lock.json');
}
// Check for merge conflicts and resolve them immediately
if (detectAndResolveConflicts(lockFilePath)) {
// Regenerate immediately to resolve conflicts
regenerateLockFile();
}
});
// Mark when generate mode starts
eventEmitter.on('generateStarted', () => {
isInGenerateMode = true;
// Clear execution times when starting a new generation
artifactExecutionTimes.clear();
artifactStartTimes.clear();
});
// Regenerate lock file after all tasks complete in generate mode
eventEmitter.on('allTasksComplete', async () => {
isInGenerateMode = false; // Mark that generate mode is complete
await regenerateLockFile();
});
// Track when artifacts begin execution
eventEmitter.on('begin', ({ artifacts }) => {
const startTime = Date.now();
for (const artifact of artifacts) {
artifactStartTimes.set(artifact.output, startTime);
}
});
// Track when artifacts end execution and calculate execution time
eventEmitter.on('end', async ({ artifacts }) => {
const endTime = Date.now();
for (const artifact of artifacts) {
const startTime = artifactStartTimes.get(artifact.output);
if (startTime !== undefined) {
const executionTime = (endTime - startTime) / 1000; // Convert to seconds
artifactExecutionTimes.set(artifact.output, executionTime);
artifactStartTimes.delete(artifact.output);
}
}
// Regenerate lock file after each task completion in daemon mode only
// (not during the initial generate phase)
if (!isInGenerateMode && config && lockFilePath) {
await regenerateLockFile();
}
});
};
exports.default = {
listener,
};