import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; const { isLocalBuildStale } = createRequire(import.meta.url)( "./bundled-plugins.cjs", ); const ID = /^[a-z0-9][a-z0-9-]*$/; const SOURCE_DIRS = new Set(["src", "locales", "migrations"]); const SOURCE_FILES = new Set(["manifest.json", "package.json"]); /** The error count from tsc --watch's "Found N errors" line, else null. */ export function parseTscStatus(line) { const match = /Found (\d+) errors?\b.*Watching for file changes/.exec(line); return match ? Number(match[1]) : null; } /** True when a changed path (relative to a plugin repo) feeds its build. */ export function isPluginSourceChange(filename) { if (!filename) return false; const parts = String(filename).replaceAll("\\", "/").split("/"); if (parts.length === 1) return SOURCE_FILES.has(parts[0]); return SOURCE_DIRS.has(parts[0]); } /** * Whether a watcher event in a plugin repo is an edit worth a rebuild. * * On Windows, fs.watch also reports a folder or file being read (its last * access time changing, which NTFS writes lazily), so reading a repo looks * like editing it. Folder events are ignored, and a file event only counts * while its sources are newer than the last build. A path that no longer * exists was deleted, which always counts. */ export function isPluginEdit(repo, filename, isStale = isLocalBuildStale) { if (!isPluginSourceChange(filename)) return false; let stat; try { stat = fs.statSync(path.join(repo, String(filename))); } catch { return true; } if (stat.isDirectory()) return false; return isStale(repo); } /** Plugin repos under dir that can be built (manifest.json and node_modules). */ export function findBuildablePlugins(dir) { const plugins = []; if (!dir || !fs.existsSync(dir)) return plugins; for (const name of fs.readdirSync(dir)) { const repo = path.join(dir, name); if (!fs.existsSync(path.join(repo, "node_modules"))) continue; try { const { id } = JSON.parse( fs.readFileSync(path.join(repo, "manifest.json"), "utf8"), ); if (typeof id === "string" && ID.test(id)) plugins.push({ id, repo }); } catch { // not a plugin repo } } return plugins.sort((a, b) => a.id.localeCompare(b.id)); } /** Runs tasks one after another; a failed task does not stop the next. */ export function createSerialQueue() { let tail = Promise.resolve(); return (task) => { const run = tail.then(task, task); tail = run.catch(() => {}); return run; }; } /** * Calls fn(key) once a key has gone quiet for ms. A key that changes again * while fn is still running for it runs once more afterwards. */ export function createDebouncer(ms, fn) { const timers = new Map(); const running = new Set(); const again = new Set(); const fire = async (key) => { timers.delete(key); if (running.has(key)) { again.add(key); return; } running.add(key); try { await fn(key); } finally { running.delete(key); if (again.delete(key)) trigger(key); } }; const trigger = (key) => { clearTimeout(timers.get(key)); timers.set( key, setTimeout(() => void fire(key), ms), ); }; trigger.cancel = () => { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); }; return trigger; } export const TARGETS = ["web", "electron", "docker"]; export const DATABASES = ["sqlite", "postgres", "mysql"]; export const HELP = `Usage: npm run dev -- [target] [options] (npm run dev:electron and npm run dev:docker pick the target) Targets web Backend and Vite on http://localhost:5173 (default) electron Backend, Vite and the desktop app docker Build the production image and run it with your local plugins Options --plugins