mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 21:32:38 +00:00
213 lines
6.9 KiB
JavaScript
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
|
|
);
|
|
}
|