Files
Termix/scripts/lib/dev-runner.mjs
T

213 lines
6.9 KiB
JavaScript

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 <dir> Plugin repos to build and watch (default ../Termix-Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
--port <n> docker target: port on this machine (default 8081)
--no-cache docker target: build the image without the layer cache
-h, --help Show this
Keys while running
r restart the backend (docker: rebuild the image)
p rebuild every local plugin
o open the app in a browser
q quit`;
/** Reads npm run dev arguments. Throws on anything it does not know. */
export function parseDevArgs(argv) {
const options = {
target: "web",
pluginsDir: null,
local: true,
db: "sqlite",
guacd: false,
fresh: false,
data: null,
port: 8081,
noCache: false,
help: false,
};
const value = (i, name) => {
const next = argv[i + 1];
if (!next || next.startsWith("--"))
throw new Error(`${name} needs a value`);
return next;
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (TARGETS.includes(arg)) options.target = arg;
else if (arg === "-h" || arg === "--help") options.help = true;
else if (arg === "--no-local") options.local = false;
else if (arg === "--guacd") options.guacd = true;
else if (arg === "--fresh") options.fresh = true;
else if (arg === "--no-cache") options.noCache = true;
else if (arg === "--plugins") options.pluginsDir = value(i++, arg);
else if (arg === "--data") options.data = value(i++, arg);
else if (arg === "--db") {
options.db = value(i++, arg).toLowerCase();
if (!DATABASES.includes(options.db)) {
throw new Error(`--db must be one of ${DATABASES.join(", ")}`);
}
} else if (arg === "--port") {
options.port = Number(value(i++, arg));
if (!Number.isInteger(options.port) || options.port < 1) {
throw new Error("--port must be a port number");
}
} else throw new Error(`Unknown option ${arg}. Run with --help.`);
}
if (options.fresh && options.data) {
throw new Error("--fresh and --data cannot be used together");
}
return options;
}
/**
* The data folder the local backend uses, or null for its own default. The
* desktop, each database engine and fresh runs get their own, so a desktop's
* local profile or another engine's keys never land in the everyday data.
*/
export function dataDirFor(options) {
if (options.data) return options.data;
const suffix = [
options.target === "electron" ? "desktop" : null,
options.db === "sqlite" ? null : options.db,
]
.filter(Boolean)
.map((part) => `-${part}`)
.join("");
if (options.fresh) return `db/data-fresh${suffix}`;
return suffix ? `db/data${suffix}` : null;
}
export function needsDocker(options) {
return (
options.target === "docker" || options.db !== "sqlite" || options.guacd
);
}