import fs from 'fs'; import fsPromises from 'fs/promises'; import path from 'path'; import { configToYaml, getPilotDeckConfigPath, maskSecrets, rawYamlToMaskedString, readPilotDeckConfigFile, validatePilotDeckConfig, } from './pilotdeckConfig.js'; import { reloadPilotDeckConfig } from './pilotdeckConfigReloader.js'; // Watches ~/.pilotdeck/pilotdeck.yaml for external edits (vim, Cursor, other IDEs) // and triggers the same reload path the UI uses on save, so *any* edit takes // effect live. When the UI itself writes the file it calls // suppressNextWatchEvent() first to avoid a redundant second reload. let watcher = null; let debounceTimer = null; let suppressCount = 0; let lastSignature = ''; let onEventHandler = null; function signatureForFile(filePath) { try { const stat = fs.statSync(filePath); return `${stat.size}:${stat.mtimeMs}`; } catch { return 'missing'; } } export function suppressNextWatchEvent() { suppressCount += 1; setTimeout(() => { suppressCount = Math.max(0, suppressCount - 1); }, 1500); } async function handleChange(configPath) { if (suppressCount > 0) return; const signature = signatureForFile(configPath); if (signature === lastSignature) return; lastSignature = signature; let record; try { record = readPilotDeckConfigFile(); } catch (error) { onEventHandler?.({ source: 'watcher', path: configPath, error: error instanceof Error ? error.message : String(error), validation: { valid: false, errors: [error instanceof Error ? error.message : String(error)], warnings: [], }, timestamp: new Date().toISOString(), }); return; } const validation = validatePilotDeckConfig(record.config); // Mirror serializeConfigResponse: emit the masked disk YAML so the // UI's hot-reload sees full router/gateway/adapters/etc. segments // when the file changes from any source (UI save, vim, external tool). const hasDiskYaml = record.rawYaml && typeof record.rawYaml === 'object' && Object.keys(record.rawYaml).length > 0; const maskedRaw = hasDiskYaml ? rawYamlToMaskedString(record.rawYaml) : configToYaml(maskSecrets(record.config)); if (!validation.valid) { onEventHandler?.({ source: 'watcher', path: record.configPath, raw: maskedRaw, validation: { valid: false, errors: validation.errors, warnings: validation.warnings }, reload: null, timestamp: new Date().toISOString(), }); return; } let reloadResult = null; try { reloadResult = await reloadPilotDeckConfig(record.config); } catch (error) { onEventHandler?.({ source: 'watcher', path: record.configPath, raw: maskedRaw, validation: { valid: true, errors: [], warnings: validation.warnings }, reload: null, error: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString(), }); return; } onEventHandler?.({ source: 'watcher', path: record.configPath, raw: maskedRaw, validation: { valid: true, errors: [], warnings: validation.warnings }, reload: reloadResult, timestamp: new Date().toISOString(), }); } export async function startPilotDeckConfigWatcher({ onEvent } = {}) { stopPilotDeckConfigWatcher(); onEventHandler = typeof onEvent === 'function' ? onEvent : null; const configPath = getPilotDeckConfigPath(); const configDir = path.dirname(configPath); const configBase = path.basename(configPath); try { await fsPromises.mkdir(configDir, { recursive: true }); } catch (error) { console.warn('[pilotdeck-config-watcher] failed to ensure config dir:', error?.message || error); return; } lastSignature = signatureForFile(configPath); try { watcher = fs.watch(configDir, { persistent: false }, (eventType, filename) => { if (filename && filename !== configBase) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; void handleChange(configPath); }, 250); }); watcher.on('error', (error) => { console.warn('[pilotdeck-config-watcher] watch error:', error?.message || error); }); console.log(`[pilotdeck-config-watcher] watching ${configPath}`); } catch (error) { console.warn('[pilotdeck-config-watcher] failed to start:', error?.message || error); } } export function stopPilotDeckConfigWatcher() { if (watcher) { try { watcher.close(); } catch { // noop } watcher = null; } if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } }