mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev
This commit is contained in:
1 parent
49fc241e06
commit
ec80a8ef8d
24 files changed
+1848
-44
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user