UNPKG

@factorialco/shadowdog

Version:

<img src="https://raw.githubusercontent.com/factorialco/shadowdog/refs/heads/main/logo.png" alt="drawing" width="100"/>

905 lines (904 loc) β€’ 43.1 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 }); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const fs_1 = require("fs"); const path = __importStar(require("path")); const http_1 = require("http"); const utils_1 = require("../utils"); const chalk_1 = __importDefault(require("chalk")); // Global state let config = null; let lockFilePath = ''; let server = null; let httpServer = null; let eventEmitter = null; // Pending changes tracking (similar to git plugin) let pendingChangedFiles = []; let isPaused = false; // Helper to read lock file data const readLockFileData = () => { if (!lockFilePath || !(0, fs_1.existsSync)(lockFilePath)) { return null; } try { const content = (0, fs_1.readFileSync)(lockFilePath, 'utf8'); return JSON.parse(content); } catch { return null; } }; // Helper to get all artifacts from config and lock file const getAllArtifacts = () => { var _a; if (!config) { return []; } const artifacts = []; const lockData = readLockFileData(); for (const watcher of config.watchers) { for (const commandConfig of watcher.commands) { for (const artifact of commandConfig.artifacts) { const lockArtifact = lockData === null || lockData === void 0 ? void 0 : lockData.artifacts.find((a) => a.output === artifact.output); artifacts.push({ output: artifact.output, command: commandConfig.command, files: (lockArtifact === null || lockArtifact === void 0 ? void 0 : lockArtifact.fileManifest.watchedFiles) || watcher.files, environment: watcher.environment, lastUpdated: lockArtifact ? new Date((0, fs_1.existsSync)(path.join(process.cwd(), artifact.output)) ? ((_a = statSyncSafe(path.join(process.cwd(), artifact.output))) === null || _a === void 0 ? void 0 : _a.mtime.toISOString()) || '' : '').toISOString() : undefined, cacheIdentifier: lockArtifact === null || lockArtifact === void 0 ? void 0 : lockArtifact.cacheIdentifier, outputSha: lockArtifact === null || lockArtifact === void 0 ? void 0 : lockArtifact.outputSha, }); } } } return artifacts; }; // Safe stat sync helper const statSyncSafe = (filePath) => { try { return (0, fs_1.statSync)(filePath); } catch { return null; } }; // Helper to handle file changes when paused const handleFileChange = (filePath) => { if (isPaused) { // Track the file change for later processing if (!pendingChangedFiles.includes(filePath)) { pendingChangedFiles.push(filePath); } return true; // Indicates the change was handled (ignored) } return false; // Indicates the change should be processed normally }; // Helper to replay pending changes when resuming const replayPendingChanges = () => { if (pendingChangedFiles.length === 0) { return; } (0, utils_1.logMessage)(`πŸ”„ Replaying ${chalk_1.default.cyan(pendingChangedFiles.length)} file changes that occurred while paused...`); const now = new Date(); pendingChangedFiles.forEach((filePath) => { try { // Touch the file to trigger file watchers (0, fs_1.utimesSync)(filePath, now, now); (0, utils_1.logMessage)(` βœ“ Replayed: ${chalk_1.default.blue(filePath)}`); } catch (error) { (0, utils_1.logMessage)(` βœ— Failed to replay: ${chalk_1.default.red(filePath)} - ${error.message}`); } }); (0, utils_1.logMessage)(`βœ… Successfully replayed ${chalk_1.default.cyan(pendingChangedFiles.length)} file changes.`); // Clear the pending changes pendingChangedFiles = []; }; // Helper to find command config for a specific artifact const findCommandForArtifact = (artifactOutput) => { if (!config) { return null; } for (const watcher of config.watchers) { for (const commandConfig of watcher.commands) { for (const artifact of commandConfig.artifacts) { if (artifact.output === artifactOutput) { return { command: commandConfig.command, workingDirectory: commandConfig.workingDirectory, files: watcher.files, environment: watcher.environment, }; } } } } return null; }; // MCP Tools definitions const TOOLS = [ { name: 'pause-shadowdog', description: 'Pauses shadowdog when running in watch mode. Use this before making changes to prevent automatic artifact generation. This properly integrates with the daemon using events.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'resume-shadowdog', description: 'Resumes shadowdog after being paused. Use this after finishing changes to re-enable automatic artifact generation. This properly integrates with the daemon using events.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get-artifacts', description: 'Retrieves information about all artifacts being generated by shadowdog, including their status, last update time, and associated files.', inputSchema: { type: 'object', properties: { filter: { type: 'string', description: 'Optional filter to search for specific artifacts by output path (case-insensitive substring match)', }, }, required: [], }, }, { name: 'compute-artifact', description: "Computes a specific artifact by triggering the daemon's artifact generation system. This properly integrates with shadowdog's artifact management system, respects configuration settings, uses the same task runner and middleware as the daemon, and provides consistent logging. This allows generating individual artifacts without running the entire build.", inputSchema: { type: 'object', properties: { artifactOutput: { type: 'string', description: 'The output path of the artifact to compute (e.g., "build/app.js", "dist/styles.css", "docs/api.md")', }, }, required: ['artifactOutput'], }, }, { name: 'get-shadowdog-status', description: 'Gets the current status of shadowdog, including daemon availability, configuration summary, and artifact information.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'clear-shadowdog-cache', description: "Clears shadowdog's local cache, lock files, and socket files. This removes all cached artifacts and forces a fresh build on the next run.", inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'compute-all-artifacts', description: "Computes all artifacts by triggering the daemon's artifact generation system for every configured artifact. This properly integrates with shadowdog's artifact management system, respects configuration settings, uses the same task runner and middleware as the daemon, and provides consistent logging. This allows generating all artifacts at once without running individual artifact commands.", inputSchema: { type: 'object', properties: {}, required: [], }, }, ]; // Initialize MCP server const initializeMCPServer = () => { if (server) { return; // Already initialized } server = new index_js_1.Server({ name: 'shadowdog-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Register tool handlers server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => { return { tools: TOOLS, }; }); server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'pause-shadowdog': { if (!eventEmitter) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } isPaused = true; eventEmitter.emit('pause'); return { content: [ { type: 'text', text: `⏸️ ${chalk_1.default.yellow('Successfully paused shadowdog.')} File changes will be tracked and replayed on resume.`, }, ], }; } case 'resume-shadowdog': { if (!eventEmitter) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } isPaused = false; eventEmitter.emit('resume'); // Replay any pending changes that occurred while paused replayPendingChanges(); return { content: [ { type: 'text', text: `▢️ ${chalk_1.default.green('Successfully resumed shadowdog.')} Automatic artifact generation is now enabled.`, }, ], }; } case 'get-artifacts': { const artifacts = getAllArtifacts(); const filter = args === null || args === void 0 ? void 0 : args.filter; const filteredArtifacts = filter ? artifacts.filter((a) => a.output.toLowerCase().includes(filter.toLowerCase())) : artifacts; const artifactInfo = filteredArtifacts.map((artifact) => { const status = (0, fs_1.existsSync)(path.join(process.cwd(), artifact.output)) ? 'βœ“ exists' : 'βœ— missing'; return { output: artifact.output, status, command: artifact.command, lastUpdated: artifact.lastUpdated || 'unknown', watchedFiles: artifact.files.length, cacheIdentifier: artifact.cacheIdentifier, outputSha: artifact.outputSha, }; }); return { content: [ { type: 'text', text: JSON.stringify({ total: filteredArtifacts.length, artifacts: artifactInfo, }, null, 2), }, ], }; } case 'compute-artifact': { const artifactOutput = args === null || args === void 0 ? void 0 : args.artifactOutput; if (!artifactOutput) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} artifactOutput parameter is required`, }, ], isError: true, }; } if (!eventEmitter) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } // Check if artifact exists in config const commandInfo = findCommandForArtifact(artifactOutput); if (!commandInfo) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} No command found for artifact '${chalk_1.default.blue(artifactOutput)}'`, }, ], isError: true, }; } // Emit event to daemon to compute the artifact eventEmitter.emit('computeArtifact', { artifactOutput }); return { content: [ { type: 'text', text: `πŸ”¨ ${chalk_1.default.blue('Artifact computation request sent for')} '${chalk_1.default.cyan(artifactOutput)}'. Check the daemon logs for progress.`, }, ], }; } case 'get-shadowdog-status': { const artifacts = getAllArtifacts(); const existingArtifacts = artifacts.filter((a) => (0, fs_1.existsSync)(path.join(process.cwd(), a.output))); return { content: [ { type: 'text', text: JSON.stringify({ daemonAvailable: eventEmitter !== null, configLoaded: config !== null, totalWatchers: (config === null || config === void 0 ? void 0 : config.watchers.length) || 0, totalArtifacts: artifacts.length, existingArtifacts: existingArtifacts.length, lockFilePath: lockFilePath, lockFileExists: (0, fs_1.existsSync)(lockFilePath), }, null, 2), }, ], }; } case 'clear-shadowdog-cache': { try { // Clear the main shadowdog temp directory const shadowdogTempDir = '/tmp/shadowdog'; if ((0, fs_1.existsSync)(shadowdogTempDir)) { (0, fs_1.rmSync)(shadowdogTempDir, { recursive: true, force: true }); } // Also clear the local lock file if it exists if (lockFilePath && (0, fs_1.existsSync)(lockFilePath)) { (0, fs_1.rmSync)(lockFilePath, { force: true }); } return { content: [ { type: 'text', text: `βœ… ${chalk_1.default.green('Successfully cleared shadowdog cache.')} All cached artifacts, lock files, and socket files have been removed.`, }, ], }; } catch (error) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error clearing cache:')} ${error.message}`, }, ], isError: true, }; } } case 'compute-all-artifacts': { if (!eventEmitter) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } if (!config) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog configuration not loaded.`, }, ], isError: true, }; } // Get all artifacts from config const allArtifacts = getAllArtifacts(); if (allArtifacts.length === 0) { return { content: [ { type: 'text', text: `ℹ️ ${chalk_1.default.blue('No artifacts found in configuration.')} Nothing to compute.`, }, ], }; } // Emit event to daemon to compute all artifacts eventEmitter.emit('computeAllArtifacts', { artifacts: allArtifacts.map((artifact) => ({ output: artifact.output })), }); return { content: [ { type: 'text', text: `πŸ”¨ ${chalk_1.default.blue('All artifacts computation request sent.')} Computing ${chalk_1.default.cyan(allArtifacts.length)} artifacts. Check the daemon logs for progress.`, }, ], }; } default: return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Unknown tool:')} ${chalk_1.default.blue(name)}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error executing tool')} '${chalk_1.default.blue(name)}': ${error.message}`, }, ], isError: true, }; } }); // Start the HTTP server const port = process.env.SHADOWDOG_MCP_PORT ? parseInt(process.env.SHADOWDOG_MCP_PORT) : 8473; const host = process.env.SHADOWDOG_MCP_HOST || 'localhost'; httpServer = (0, http_1.createServer)(async (req, res) => { // Set CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } if (req.method !== 'POST' || req.url !== '/mcp') { res.writeHead(404); res.end('Not Found'); return; } try { let body = ''; req.on('data', (chunk) => { body += chunk.toString(); }); req.on('end', async () => { try { const request = JSON.parse(body); // Handle the MCP request let response; if (request.method === 'initialize') { response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, }, serverInfo: { name: 'shadowdog-mcp', version: '1.0.0', }, }, }; } else if (request.method === 'tools/list') { response = { jsonrpc: '2.0', id: request.id, result: { tools: TOOLS }, }; } else if (request.method === 'tools/call') { const toolName = request.params.name; const toolArgs = request.params.arguments || {}; // Call the appropriate tool let result; switch (toolName) { case 'pause-shadowdog': if (!eventEmitter) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } else { isPaused = true; eventEmitter.emit('pause'); result = { content: [ { type: 'text', text: `⏸️ ${chalk_1.default.yellow('Successfully paused shadowdog.')} File changes will be tracked and replayed on resume.`, }, ], }; } break; case 'resume-shadowdog': if (!eventEmitter) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } else { isPaused = false; eventEmitter.emit('resume'); // Replay any pending changes that occurred while paused replayPendingChanges(); result = { content: [ { type: 'text', text: `▢️ ${chalk_1.default.green('Successfully resumed shadowdog.')} Automatic artifact generation is now enabled.`, }, ], }; } break; case 'get-artifacts': { const artifacts = getAllArtifacts(); const filter = toolArgs.filter; const filteredArtifacts = filter ? artifacts.filter((a) => a.output.toLowerCase().includes(filter.toLowerCase())) : artifacts; const artifactInfo = filteredArtifacts.map((artifact) => { const status = (0, fs_1.existsSync)(path.join(process.cwd(), artifact.output)) ? 'βœ“ exists' : 'βœ— missing'; return { output: artifact.output, status, command: artifact.command, lastUpdated: artifact.lastUpdated || 'unknown', watchedFiles: artifact.files.length, cacheIdentifier: artifact.cacheIdentifier, outputSha: artifact.outputSha, }; }); result = { content: [ { type: 'text', text: JSON.stringify({ total: filteredArtifacts.length, artifacts: artifactInfo }, null, 2), }, ], }; break; } case 'compute-artifact': { const artifactOutput = toolArgs.artifactOutput; if (!artifactOutput) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} artifactOutput parameter is required`, }, ], isError: true, }; } else if (!eventEmitter) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } else { const commandInfo = findCommandForArtifact(artifactOutput); if (!commandInfo) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} No command found for artifact '${chalk_1.default.blue(artifactOutput)}'`, }, ], isError: true, }; } else { eventEmitter.emit('computeArtifact', { artifactOutput }); result = { content: [ { type: 'text', text: `πŸ”¨ ${chalk_1.default.blue('Artifact computation request sent for')} '${chalk_1.default.cyan(artifactOutput)}'. Check the daemon logs for progress.`, }, ], }; } } break; } case 'get-shadowdog-status': { const allArtifacts = getAllArtifacts(); const existingArtifacts = allArtifacts.filter((a) => (0, fs_1.existsSync)(path.join(process.cwd(), a.output))); result = { content: [ { type: 'text', text: JSON.stringify({ daemonAvailable: eventEmitter !== null, configLoaded: config !== null, totalWatchers: (config === null || config === void 0 ? void 0 : config.watchers.length) || 0, totalArtifacts: allArtifacts.length, existingArtifacts: existingArtifacts.length, lockFilePath: lockFilePath, lockFileExists: (0, fs_1.existsSync)(lockFilePath), }, null, 2), }, ], }; break; } case 'clear-shadowdog-cache': { try { // Clear the main shadowdog temp directory const shadowdogTempDir = '/tmp/shadowdog'; if ((0, fs_1.existsSync)(shadowdogTempDir)) { (0, fs_1.rmSync)(shadowdogTempDir, { recursive: true, force: true }); } // Also clear the local lock file if it exists if (lockFilePath && (0, fs_1.existsSync)(lockFilePath)) { (0, fs_1.rmSync)(lockFilePath, { force: true }); } result = { content: [ { type: 'text', text: `βœ… ${chalk_1.default.green('Successfully cleared shadowdog cache.')} All cached artifacts, lock files, and socket files have been removed.`, }, ], }; } catch (error) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error clearing cache:')} ${error.message}`, }, ], isError: true, }; } break; } case 'compute-all-artifacts': { if (!eventEmitter) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog daemon not available.`, }, ], isError: true, }; } else if (!config) { result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Error:')} Shadowdog configuration not loaded.`, }, ], isError: true, }; } else { // Get all artifacts from config const allArtifacts = getAllArtifacts(); if (allArtifacts.length === 0) { result = { content: [ { type: 'text', text: `ℹ️ ${chalk_1.default.blue('No artifacts found in configuration.')} Nothing to compute.`, }, ], }; } else { // Emit event to daemon to compute all artifacts eventEmitter.emit('computeAllArtifacts', { artifacts: allArtifacts.map((artifact) => ({ output: artifact.output })), }); result = { content: [ { type: 'text', text: `πŸ”¨ ${chalk_1.default.blue('All artifacts computation request sent.')} Computing ${chalk_1.default.cyan(allArtifacts.length)} artifacts. Check the daemon logs for progress.`, }, ], }; } } break; } default: result = { content: [ { type: 'text', text: `❌ ${chalk_1.default.red('Unknown tool:')} ${chalk_1.default.blue(toolName)}`, }, ], isError: true, }; } response = { jsonrpc: '2.0', id: request.id, result, }; } else { response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' }, }; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' }, })); } }); } catch { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error' }, })); } }); httpServer.listen(port, host, () => { const serverUrl = `http://${host}:${port}/mcp`; (0, utils_1.logMessage)(`πŸ”Œ MCP Server initialized and ready at ${chalk_1.default.green(serverUrl)}`); // Only show connection details when running in MCP mode if (process.argv.includes('--mcp')) { (0, utils_1.logMessage)(`πŸ“‹ To connect from Cursor, add to your MCP config:`); (0, utils_1.logMessage)(` ${chalk_1.default.yellow(`"shadowdog-mcp": { "url": "${serverUrl}" }`)}`); (0, utils_1.logMessage)(` Available tools: pause-shadowdog, resume-shadowdog, get-artifacts, compute-artifact, compute-all-artifacts, get-shadowdog-status, clear-shadowdog-cache`); (0, utils_1.logMessage)(`πŸ”— Setup guide: ${chalk_1.default.underline('https://cursor.com/docs/context/mcp/install-links')}`); } }); httpServer.on('error', (error) => { (0, utils_1.logMessage)(`❌ ${chalk_1.default.red('HTTP Server error:')} ${error.message}`); }); }; // Event listener plugin implementation const listener = (eventEmitterParam) => { // Store event emitter reference eventEmitter = eventEmitterParam; // Initialize lock file path lockFilePath = path.resolve(process.cwd(), 'shadowdog-lock.json'); // Store config reference when it's loaded eventEmitter.on('configLoaded', ({ config: loadedConfig }) => { config = loadedConfig; }); // Initialize MCP server when shadowdog initializes eventEmitter.on('initialized', () => { initializeMCPServer(); }); // Listen for file changes to track them when paused eventEmitter.on('changed', ({ path: filePath }) => { handleFileChange(filePath); }); // Clean up on exit eventEmitter.on('exit', () => { if (httpServer) { httpServer.close(() => { (0, utils_1.logMessage)(`πŸ”Œ MCP HTTP Server closed`); }); } if (server) { server.close().catch((error) => { (0, utils_1.logMessage)(`❌ Failed to close MCP server: ${chalk_1.default.red(error.message)}`); }); } }); }; exports.default = { listener, };