feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

This commit is contained in:
LukeGus committed 2026-10-06 15:55:00 -05:00
1 parent 49fc241e06
commit ec80a8ef8d
24 files changed
+1848 -44

No files matched your search

+5 -2
View File
@@ -231,7 +231,10 @@ function isLocalBuildStale(repo) {
);
}
/** Builds each listed plugin repo whose build is missing or out of date. */
/**
* Builds each plugin repo whose build is missing or out of date. ids limits
* it to those plugins; null builds every one.
*/
function rebuildLocalPlugins(dir, ids, run) {
const rebuilt = [];
if (!dir || !fs.existsSync(dir)) return rebuilt;
@@ -246,7 +249,7 @@ function rebuildLocalPlugins(dir, ids, run) {
} catch {
continue;
}
if (!ids.has(id) || !isLocalBuildStale(repo)) continue;
if ((ids && !ids.has(id)) || !isLocalBuildStale(repo)) continue;
run(repo, id);
rebuilt.push(id);
}
+109
View File
@@ -0,0 +1,109 @@
export const NETWORK = "termix-dev";
export const APP_CONTAINER = "termix-dev";
export const APP_IMAGE = "termix:dev";
/** Side containers dev:all can run. hostPort is what the local backend uses. */
export const SERVICES = {
postgres: {
container: "termix-dev-postgres",
image: "postgres:16",
hostPort: 55432,
port: 5432,
env: {
POSTGRES_USER: "termix",
POSTGRES_PASSWORD: "termix",
POSTGRES_DB: "termix",
},
volume: ["termix-dev-postgres", "/var/lib/postgresql/data"],
// Over TCP, so the server that only runs during first-time setup does
// not count as ready.
ready: ["pg_isready", "-h", "127.0.0.1", "-U", "termix", "-d", "termix"],
},
mysql: {
container: "termix-dev-mysql",
image: "mysql:8",
hostPort: 33306,
port: 3306,
env: { MYSQL_ROOT_PASSWORD: "termix", MYSQL_DATABASE: "termix" },
volume: ["termix-dev-mysql", "/var/lib/mysql"],
ready: ["mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-ptermix"],
},
guacd: {
container: "termix-dev-guacd",
image: "guacamole/guacd:1.6.0",
hostPort: 4822,
port: 4822,
env: {},
volume: null,
ready: null,
},
};
function envArgs(env) {
return Object.entries(env).flatMap(([key, value]) => [
"-e",
`${key}=${value}`,
]);
}
export function serviceRunArgs(service) {
return [
"run",
"-d",
"--name",
service.container,
"--network",
NETWORK,
"-p",
`${service.hostPort}:${service.port}`,
...envArgs(service.env),
...(service.volume
? ["-v", `${service.volume[0]}:${service.volume[1]}`]
: []),
service.image,
];
}
/**
* Environment that points a backend at the side containers. inDocker means
* the backend runs in the dev network and reaches them by name.
*/
export function serviceEnv(options, inDocker) {
const env = {};
const address = (service) =>
inDocker
? `${service.container}:${service.port}`
: `127.0.0.1:${service.hostPort}`;
if (options.db === "postgres") {
env.DATABASE_DIALECT = "postgres";
env.DATABASE_URL = `postgres://termix:termix@${address(SERVICES.postgres)}/termix`;
} else if (options.db === "mysql") {
env.DATABASE_DIALECT = "mysql";
env.DATABASE_URL = `mysql://root:termix@${address(SERVICES.mysql)}/termix`;
}
if (options.guacd) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
}
return env;
}
export function appRunArgs({ port, env, dataMount, pluginsDir }) {
return [
"run",
"-d",
"--name",
APP_CONTAINER,
"--network",
NETWORK,
"-p",
`${port}:8080`,
...envArgs({ PORT: "8080", NODE_ENV: "development", ...env }),
"-v",
`${dataMount}:/app/data`,
"-v",
`${pluginsDir}:/app/dist/plugins`,
APP_IMAGE,
];
}
+185
View File
@@ -0,0 +1,185 @@
import fs from "node:fs";
import path from "node:path";
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]);
}
/** 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:all -- [target] [options]
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 dev:all 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
);
}