import { isCommandCtor } from "@/utils"; import type { CommandRegistry } from "./commandRegistry"; import path from "node:path"; import { watch, existsSync } from "node:fs"; import { createRequire } from "node:module"; import { Logger } from "tslog"; const logger = new Logger({ type: "pretty", name: "Watch", }); // Totally intentional, we want to torture Bun into force import changed/renamed files :) const _require = createRequire(import.meta.url); // Workaround for atomic files changes (mainly in other editors such as nvim, vscode, etc...) const debounceTimers = new Map(); async function reloadFile( registry: CommandRegistry, prefix: string, absPath: string, ): Promise { const fileUrl = Bun.pathToFileURL(absPath).href; try { delete _require.cache[fileUrl]; const mod = await import(fileUrl); // Collect all valid command constructors from this file first, // before touching the registry, so we know if anything actually loaded. const ctors = Object.values(mod).filter(isCommandCtor); if (ctors.length === 0) { // File exists but has no @Command decorator yet — skip silently. // It will be picked up automatically on the next save once the decorator is added. logger.info(`Skipped ${path.basename(absPath)}: no @Command decorator found`); return; } // Only unregister the old version once we know the new one is valid. registry.unregisterFile(absPath); for (const ctor of ctors) { registry.register(prefix, absPath, ctor); } logger.info(`Reloaded: ${absPath}`); } catch (e) { logger.error(`Failed to reload ${absPath}:`, e); } } export function watchCommands( registry: CommandRegistry, prefix: string, dir = "src/commands", ): void { // We intentionally ignore the event type here. // On Linux, editors do atomic saves (write tmp → rename into place), // which means "change" never fires — only "rename" does. // So we check existsSync after a short debounce instead. watch(dir, { recursive: true }, (_, filename) => { if (!filename?.endsWith(".ts")) return; const absPath = path.resolve(dir, filename); const existing = debounceTimers.get(absPath); if (existing) clearTimeout(existing); debounceTimers.set( absPath, setTimeout(() => { debounceTimers.delete(absPath); if (existsSync(absPath)) { logger.info(`Reloading ${absPath}`); reloadFile(registry, prefix, absPath); } else { registry.unregisterFile(absPath); logger.info(`Unloaded: ${absPath}`); } }, 50), ); }); logger.info(`Watching ${dir}/ for changes...`); }