mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 13:21:47 +00:00
209 lines
6.0 KiB
JavaScript
209 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Core knows no plugin by name.
|
|
*
|
|
* Fails when anything under src/ spells a plugin id: a bare literal
|
|
* ("docker"), a plugin route ("/plugin-api/docker/..."), or an action, slot
|
|
* or permission id that starts with one ("docker.open"). What core needs from
|
|
* a plugin comes through the registries instead. Regex literals are read too,
|
|
* so /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
|
|
*
|
|
* Plugins live in their own repos, so the ids are the official ones below
|
|
* plus anything in docker/bundled-plugins.json. electron/ and vite.config.ts
|
|
* are read as well.
|
|
*
|
|
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
|
|
* src/backend/upgrade/: the one-time
|
|
* 2.8 to 2.9 data moves have to name the plugin each piece of data moves to,
|
|
* the same way LEGACY_TABLE_OWNERS in the SDK names the plugin that adopts
|
|
* each legacy table.
|
|
*
|
|
* One plugin id is also an SSH term: "totp" is the one-time-code prompt kind
|
|
* in keyboard-interactive auth, which the connect pipeline in
|
|
* src/backend/hosts/connect/ classifies. It is not the login plugin.
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const SSH_TERMS = new Set(["totp"]);
|
|
|
|
const OFFICIAL_IDS = [
|
|
"acme-ssl",
|
|
"ai",
|
|
"alerts",
|
|
"automations",
|
|
"docker",
|
|
"file-manager",
|
|
"fleets",
|
|
"homepage",
|
|
"host-metrics",
|
|
"ldap",
|
|
"network-topology",
|
|
"opkssh",
|
|
"proxmox",
|
|
"remote-desktop",
|
|
"secret-sources",
|
|
"serial",
|
|
"session-recording",
|
|
"session-sharing",
|
|
"snippets",
|
|
"ssh-terminal",
|
|
"sso",
|
|
"step-ca",
|
|
"tailscale",
|
|
"telemetry",
|
|
"termix-identity",
|
|
"tmux-monitor",
|
|
"totp",
|
|
"tunnels",
|
|
"vault",
|
|
"wake-on-lan",
|
|
"warpgate",
|
|
"web-endpoint",
|
|
"webauthn",
|
|
"workspaces",
|
|
];
|
|
|
|
function paths(root) {
|
|
const src = path.join(root, "src");
|
|
return {
|
|
root,
|
|
src,
|
|
bundled: path.join(root, "docker", "bundled-plugins.json"),
|
|
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
|
|
exempt: [
|
|
path.join(src, "backend", "upgrade"),
|
|
path.join(src, "backend", "tests"),
|
|
path.join(src, "ui", "tests"),
|
|
path.join(src, "ui", "locales"),
|
|
],
|
|
sshConnect: path.join(src, "backend", "hosts", "connect"),
|
|
};
|
|
}
|
|
|
|
function pluginIds(bundledFile) {
|
|
const ids = new Set(OFFICIAL_IDS);
|
|
if (bundledFile && fs.existsSync(bundledFile)) {
|
|
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
|
|
.plugins ?? []) {
|
|
if (typeof entry?.id === "string") ids.add(entry.id);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function walk(dir, exempt, out) {
|
|
if (exempt.includes(dir) || !fs.existsSync(dir)) return out;
|
|
if (fs.statSync(dir).isFile()) {
|
|
out.push(dir);
|
|
return out;
|
|
}
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "node_modules") continue;
|
|
walk(full, exempt, out);
|
|
} else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Every string literal in a file, template literals included. */
|
|
function literals(source) {
|
|
const out = [];
|
|
const pattern =
|
|
/"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
|
|
for (const match of source.matchAll(pattern)) {
|
|
out.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Every regex literal in a file, with escaped slashes undone, so a pattern
|
|
* reads the way the path it matches does.
|
|
*/
|
|
function regexLiterals(source) {
|
|
const out = [];
|
|
const pattern =
|
|
/(^|[=(,:[!&|?{};]|\breturn)\s*\/((?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)\/[dgimsuyv]*/gm;
|
|
for (const match of source.matchAll(pattern)) {
|
|
const body = match[2];
|
|
// A comment, not a pattern.
|
|
if (body.startsWith("/") || body.startsWith("*")) continue;
|
|
out.push(body.replace(/\\(.)/g, "$1"));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function scan(root = path.resolve(__dirname, "..")) {
|
|
const { src, bundled, extra, exempt, sshConnect } = paths(root);
|
|
const ids = pluginIds(bundled);
|
|
const found = {};
|
|
const add = (file, what) => {
|
|
const key = path.relative(root, file).replaceAll("\\", "/");
|
|
(found[key] ??= new Set()).add(what);
|
|
};
|
|
|
|
const files = walk(src, exempt, []);
|
|
for (const target of extra) walk(target, exempt, files);
|
|
for (const file of files) {
|
|
// A line marked "plugin-id-ok" names a plugin on purpose, such as a key
|
|
// an older release stored; the marker has to say why.
|
|
const source = fs
|
|
.readFileSync(file, "utf8")
|
|
.split("\n")
|
|
.filter((line) => !/plugin-id-ok: \S/.test(line))
|
|
.join("\n");
|
|
const inConnect = file.startsWith(sshConnect + path.sep);
|
|
for (const text of literals(source)) {
|
|
const sshTerm = inConnect && SSH_TERMS.has(text);
|
|
if (ids.has(text) && !sshTerm) add(file, text);
|
|
for (const route of text.matchAll(
|
|
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
|
|
)) {
|
|
if (ids.has(route[1])) add(file, route[0]);
|
|
}
|
|
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
|
|
if (prefix && ids.has(prefix[1])) add(file, text);
|
|
}
|
|
for (const pattern of regexLiterals(source)) {
|
|
for (const route of pattern.matchAll(
|
|
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
|
|
)) {
|
|
if (ids.has(route[1])) add(file, `/${pattern}/`);
|
|
}
|
|
const sshTerm = inConnect && SSH_TERMS.has(pattern);
|
|
if (ids.has(pattern.replace(/^\^|\$$/g, "")) && !sshTerm) {
|
|
add(file, `/${pattern}/`);
|
|
}
|
|
}
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
|
|
);
|
|
}
|
|
|
|
function main() {
|
|
const found = scan();
|
|
const entries = Object.entries(found);
|
|
if (process.argv.includes("--list")) {
|
|
console.log(JSON.stringify(found, null, 2));
|
|
return;
|
|
}
|
|
if (entries.length > 0) {
|
|
console.error("Core names plugins it should reach through a registry:");
|
|
for (const [file, list] of entries) {
|
|
console.error(` ${file}: ${list.join(", ")}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) main();
|
|
|
|
module.exports = { scan, regexLiterals };
|