feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
/**
* Builds every plugin workspace, then stages the result in dist/plugins.
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* Only what a server needs at runtime is copied: the manifest, the built
* bundles, locales and migrations. Source and tests stay out of the image.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const destination = path.join(root, "dist", "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const SHIPPED = ["manifest.json", "dist", "locales", "migrations", "README.md"];
if (!fs.existsSync(source)) {
console.log("No plugins/ directory, nothing to bundle.");
process.exit(0);
}
const pluginIds = fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(source, entry.name, "manifest.json")),
)
.map((entry) => entry.name);
fs.rmSync(destination, { recursive: true, force: true });
for (const id of pluginIds) {
const pluginDir = path.join(source, id);
execFileSync(process.execPath, [cli, "build"], {
cwd: pluginDir,
stdio: "inherit",
});
const built = path.join(pluginDir, "dist", "backend.js");
if (!fs.existsSync(built)) {
throw new Error(`${id} produced no dist/backend.js`);
}
const outDir = path.join(destination, id);
fs.mkdirSync(outDir, { recursive: true });
for (const entry of SHIPPED) {
const from = path.join(pluginDir, entry);
if (!fs.existsSync(from)) continue;
fs.cpSync(from, path.join(outDir, entry), { recursive: true });
}
}
console.log(`Bundled ${pluginIds.length} plugin(s): ${pluginIds.join(", ")}`);
+120
View File
@@ -0,0 +1,120 @@
/**
* Counts the import-boundary debt between core and plugins, and fails when it
* grows.
*
* The eslint fence marks these directions as warnings because there is nowhere
* for them to go yet (see packages/plugin-sdk/ARCHITECTURE.md). A warning does
* not fail a build, so this script holds the line instead: every offender is
* listed in plugin-boundary-allowlist.json, a new one fails, and an entry that
* stopped being an offender fails too, so the list shrinks as B steps land
* rather than rotting. D1 empties it and the eslint rules become errors.
*/
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const allowlistPath = path.join(
root,
"scripts",
"plugin-boundary-allowlist.json",
);
const norm = (p) => p.split(path.sep).join("/");
const DIRECTIONS = {
// The shell importing plugin components. A7 empties this.
"ui-to-plugin": {
roots: ["src/ui"],
skip: (rel) => rel.includes("/tests/"),
pattern: /["'][^"']*\/plugins\/[a-z0-9-]+\/src\/(frontend|backend)\//,
},
// A plugin backend reaching core by relative path. D1 empties this.
"plugin-to-core": {
roots: ["plugins"],
only: (rel) => /^plugins\/[a-z0-9-]+\/src\//.test(rel),
pattern: /["'](?:\.\.\/)+src\/(backend|types)\//,
},
// One plugin importing another's source. B18 empties this.
"plugin-to-plugin": {
roots: ["plugins"],
only: (rel) => /^plugins\/[a-z0-9-]+\/src\//.test(rel),
pattern: /["'](?:\.\.\/)+(?!src\/)[a-z0-9-]+\/src\/(backend|frontend)\//,
},
};
function walk(dir, out = []) {
if (!fs.existsSync(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" || entry.name === "dist") continue;
walk(full, out);
} else if (/\.(ts|tsx|mjs)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
function offendersFor(name, config) {
const found = new Set();
for (const base of config.roots) {
for (const file of walk(path.join(root, base))) {
const rel = norm(path.relative(root, file));
if (config.only && !config.only(rel)) continue;
if (config.skip && config.skip(rel)) continue;
const source = fs.readFileSync(file, "utf8");
if (config.pattern.test(source)) found.add(rel);
}
}
return [...found].sort();
}
const allowlist = fs.existsSync(allowlistPath)
? JSON.parse(fs.readFileSync(allowlistPath, "utf8"))
: {};
if (process.argv.includes("--write")) {
const next = {};
for (const [name, config] of Object.entries(DIRECTIONS)) {
next[name] = offendersFor(name, config);
}
fs.writeFileSync(allowlistPath, JSON.stringify(next, null, 2) + "\n");
console.log("Wrote the allowlist from the current tree.");
process.exit(0);
}
let failed = false;
for (const [name, config] of Object.entries(DIRECTIONS)) {
const offenders = offendersFor(name, config);
const allowed = new Set(allowlist[name] ?? []);
const added = offenders.filter((file) => !allowed.has(file));
const stale = [...allowed].filter((file) => !offenders.includes(file));
if (added.length > 0) {
failed = true;
console.error(`\n${name}: ${added.length} new violation(s):`);
for (const file of added) console.error(` ${file}`);
}
if (stale.length > 0) {
failed = true;
console.error(
`\n${name}: ${stale.length} allowlist entry(ies) no longer violate. Remove them:`,
);
for (const file of stale) console.error(` ${file}`);
}
if (added.length === 0 && stale.length === 0) {
console.log(`${name}: ${offenders.length} remaining (allowlisted)`);
}
}
if (failed) {
console.error(
"\nRun `node scripts/check-plugin-boundaries.cjs --write` if the change is intentional.",
);
process.exit(1);
}
-180
View File
@@ -1,180 +0,0 @@
/**
* Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins
* so bundled first-party plugins ship with a built server.
*
* Most plugin backend entries are hand-written .mjs plus a manifest.json --
* tsc only emits .ts, so without a copy step the plugins directory simply
* would not exist in dist and the loader would find nothing.
*
* docker, host-metrics and remote-desktop are the exception: their backends
* are real TypeScript, physically relocated from src/backend/hosts/docker/,
* src/backend/hosts/metrics/ and src/backend/hosts/guacamole/ rather than
* kept as hand-written JS, because each is thousands of lines of typed
* SSH/session (or, for remote-desktop, guacd protocol) logic that is not
* worth hand-transpiling. tsconfig.plugins.json compiles each plugin backend's
* TypeScript to a sibling .js next to its source. Their imports into core
* (e.g. "../../../src/backend/utils/logger.js") are written relative to the
* TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not
* under tsconfig.node.json's rootDir and cannot be added to it without
* changing every existing dist/backend/backend/... path in the codebase.
* That means the emitted .js still points at src/backend/, which does not
* exist in a built server -- only its compiled counterpart at
* dist/backend/backend/ does. rewriteCoreImports() below corrects that one
* prefix after compilation, for every plugins/*\/backend directory tsc
* compiled, before the directory is copied into dist/.
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const destination = path.join(root, "dist", "plugins");
if (!fs.existsSync(source)) {
console.log("No plugins/ directory, nothing to bundle.");
process.exit(0);
}
const SRC_BACKEND_PREFIX = "../../../src/backend/";
const COMPILED_BACKEND_PREFIX = "../../../backend/backend/";
function compilePluginBackends() {
execSync("npx tsc -p tsconfig.plugins.json", {
cwd: root,
stdio: "inherit",
});
}
/**
* Rewrites the compiled plugin JS's imports into core from the source-tree
* path tsc needed to resolve them, to the compiled-output path they need to
* resolve at runtime. See the module comment above for why this exists.
*/
function rewriteCoreImports(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
rewriteCoreImports(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
const original = fs.readFileSync(entryPath, "utf8");
if (!original.includes(SRC_BACKEND_PREFIX)) continue;
const rewritten = original
.split(SRC_BACKEND_PREFIX)
.join(COMPILED_BACKEND_PREFIX);
fs.writeFileSync(entryPath, rewritten);
}
}
/**
* tsconfig.plugins.json has rootDir/outDir "." so the docker plugin's .js
* lands beside its own .ts, but tsc also pulls every file the plugin
* imports into the same program for type-checking -- core utils, repository
* factories, src/types -- and stray-emits a sibling .js next to each of
* those .ts files too, even though none of that code is actually compiled
* by this pass (only referenced for types/signatures). Those stray files
* are not part of the normal build and must not leak into dist/ or the
* working tree, so anything under src/ that is a .js with no git history
* and a same-named .ts sibling gets removed after every compile.
*/
function isGitTracked(filePath) {
try {
execSync(`git ls-files --error-unmatch "${filePath}"`, {
cwd: root,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
function removeStrayEmits(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
removeStrayEmits(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
const tsSibling = entryPath.slice(0, -".js".length) + ".ts";
// Never touch a tracked file, even if some future .ts/.js pair
// legitimately coexists -- only remove what is both untracked and has a
// .ts sibling, which is what a stray tsc emit always looks like.
if (fs.existsSync(tsSibling) && !isGitTracked(entryPath)) {
fs.rmSync(entryPath);
}
}
}
/**
* Every plugin directory with a backend/ that tsc actually compiled (i.e. it
* has a .ts file in it, not just a hand-written index.mjs). Scanned rather
* than hardcoded so a future plugin only needs a tsconfig.plugins.json entry,
* not a second call site here.
*/
function compiledPluginBackendDirs() {
return fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(source, entry.name, "backend"))
.filter(
(dir) =>
fs.existsSync(dir) &&
fs.readdirSync(dir).some((name) => name.endsWith(".ts")),
);
}
/**
* Deletes every .js file tsc just emitted directly under a plugin's own
* backend/ directory (recursively), leaving its .ts siblings untouched.
*
* Without this, the compiled .js sits next to the .ts in plugins/ after every
* build, shadowing the source when vitest/tsx resolve an import -- and since
* those .js files were rewritten for the dist/ layout (see rewriteCoreImports),
* their relative paths are wrong at the plugins/ depth, so anything importing
* them from a test fails with "Cannot find module". .gitignore already
* expects plugins/*\/backend/**\/*.js to never be tracked; this is what keeps
* the working tree matching that after a local build, the same way dist/ is
* expected to hold the only copy of compiled plugin output.
*/
function removeCompiledPluginEmits(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
removeCompiledPluginEmits(entryPath);
continue;
}
if (entry.name.endsWith(".js")) fs.rmSync(entryPath);
}
}
compilePluginBackends();
const compiledDirs = compiledPluginBackendDirs();
for (const dir of compiledDirs) {
rewriteCoreImports(dir);
}
removeStrayEmits(path.join(root, "src"));
fs.rmSync(destination, { recursive: true, force: true });
fs.cpSync(source, destination, { recursive: true });
for (const dir of compiledDirs) {
removeCompiledPluginEmits(dir);
}
const bundled = fs
.readdirSync(destination, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
console.log(`Bundled ${bundled.length} plugin(s): ${bundled.join(", ")}`);
+98
View File
@@ -0,0 +1,98 @@
{
"ui-to-plugin": [
"src/ui/AppShell.tsx",
"src/ui/dashboard/DashboardTab.tsx",
"src/ui/features/ai/useMentions.ts",
"src/ui/features/collab/CollabGuestView.tsx",
"src/ui/features/collab/CollabRoomTab.tsx",
"src/ui/features/homepage/widgets/DockerWidget.tsx",
"src/ui/features/session-sharing/SharedSessionView.tsx",
"src/ui/features/terminal/Terminal.tsx",
"src/ui/main-axios.ts",
"src/ui/shell/tabUtils.tsx",
"src/ui/sidebar/AdminSettingsPanel.tsx",
"src/ui/sidebar/HostEditor.tsx",
"src/ui/sidebar/HostEditorWebUiSection.tsx",
"src/ui/sidebar/HostsPanel.tsx",
"src/ui/sidebar/quick-connect-host.ts",
"src/ui/sidebar/tree/HostItem/HostItem.tsx"
],
"plugin-to-core": [
"plugins/ai/src/backend/egress.ts",
"plugins/ai/src/backend/engine.ts",
"plugins/ai/src/backend/gating.ts",
"plugins/ai/src/backend/index.ts",
"plugins/ai/src/backend/providers/http.ts",
"plugins/ai/src/backend/routes.ts",
"plugins/ai/src/backend/tools/executor.ts",
"plugins/ai/src/backend/tools/read-tools.ts",
"plugins/automations/src/backend/actions/host-targets.ts",
"plugins/automations/src/backend/actions/index.ts",
"plugins/automations/src/backend/actions/types.ts",
"plugins/automations/src/backend/conditions.ts",
"plugins/automations/src/backend/docker-watcher.ts",
"plugins/automations/src/backend/engine.ts",
"plugins/automations/src/backend/headless-viewer.ts",
"plugins/automations/src/backend/http.ts",
"plugins/automations/src/backend/notify.ts",
"plugins/automations/src/backend/routes.ts",
"plugins/automations/src/backend/scheduler.ts",
"plugins/automations/src/backend/triggers.ts",
"plugins/docker/src/backend/console.ts",
"plugins/docker/src/backend/container-routes.ts",
"plugins/docker/src/backend/index.ts",
"plugins/docker/src/backend/routes.ts",
"plugins/docker/src/backend/session-manager.ts",
"plugins/fleets/src/backend/routes.ts",
"plugins/host-metrics/src/backend/automation-bridge.ts",
"plugins/host-metrics/src/backend/helpers.ts",
"plugins/host-metrics/src/backend/history-routes.ts",
"plugins/host-metrics/src/backend/managers/cron.ts",
"plugins/host-metrics/src/backend/managers/firewall.ts",
"plugins/host-metrics/src/backend/managers/health.ts",
"plugins/host-metrics/src/backend/managers/index.ts",
"plugins/host-metrics/src/backend/managers/logs.ts",
"plugins/host-metrics/src/backend/managers/packages.ts",
"plugins/host-metrics/src/backend/managers/processes.ts",
"plugins/host-metrics/src/backend/managers/route-helpers.ts",
"plugins/host-metrics/src/backend/managers/services.ts",
"plugins/host-metrics/src/backend/managers/simple-reads.ts",
"plugins/host-metrics/src/backend/managers/ssl.ts",
"plugins/host-metrics/src/backend/managers/tailscale.ts",
"plugins/host-metrics/src/backend/managers/types.ts",
"plugins/host-metrics/src/backend/managers/users.ts",
"plugins/host-metrics/src/backend/managers/wireguard.ts",
"plugins/host-metrics/src/backend/preferences-routes.ts",
"plugins/host-metrics/src/backend/routes.ts",
"plugins/host-metrics/src/backend/sessions.ts",
"plugins/host-metrics/src/backend/settings-routes.ts",
"plugins/host-metrics/src/backend/viewer-routes.ts",
"plugins/host-metrics/src/backend/widgets/cpu-collector.ts",
"plugins/host-metrics/src/backend/widgets/disk-collector.ts",
"plugins/host-metrics/src/backend/widgets/firewall-collector.ts",
"plugins/host-metrics/src/backend/widgets/gpu-collector.ts",
"plugins/host-metrics/src/backend/widgets/login-stats-collector.ts",
"plugins/host-metrics/src/backend/widgets/memory-collector.ts",
"plugins/host-metrics/src/backend/widgets/network-collector.ts",
"plugins/host-metrics/src/backend/widgets/ports-collector.ts",
"plugins/host-metrics/src/backend/widgets/processes-collector.ts",
"plugins/host-metrics/src/backend/widgets/system-collector.ts",
"plugins/host-metrics/src/backend/widgets/temperature-collector.ts",
"plugins/host-metrics/src/backend/widgets/uptime-collector.ts",
"plugins/network-topology/src/backend/routes.ts",
"plugins/proxmox/src/backend/routes.ts",
"plugins/remote-desktop/src/backend/guacamole-server.ts",
"plugins/remote-desktop/src/backend/index.ts",
"plugins/remote-desktop/src/backend/routes.ts",
"plugins/remote-desktop/src/backend/token-service.ts",
"plugins/ssh-terminal/src/backend/index.ts",
"plugins/tailscale/src/backend/routes.ts",
"plugins/web-endpoint/src/backend/routes.ts",
"plugins/workspaces/src/backend/routes.ts"
],
"plugin-to-plugin": [
"plugins/ai/src/backend/tools/executor.ts",
"plugins/host-metrics/src/backend/automation-bridge.ts",
"plugins/host-metrics/src/backend/routes.ts"
]
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Runs every plugin's own vitest suite.
*
* Core's `npm run test` covers src/ only. A plugin's tests live beside it and
* run under its own config, so a plugin that moves out of this repo keeps a
* working suite.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const pluginIds = fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(source, entry.name, "vitest.config.ts")),
)
.map((entry) => entry.name);
const failed = [];
for (const id of pluginIds) {
const pluginDir = path.join(source, id);
const hasTests = ["tests/backend", "tests/frontend"].some((dir) => {
const full = path.join(pluginDir, dir);
return (
fs.existsSync(full) &&
fs
.readdirSync(full, { recursive: true })
.some(
(name) =>
String(name).endsWith(".test.ts") ||
String(name).endsWith(".test.tsx"),
)
);
});
if (!hasTests) continue;
const result = spawnSync(process.execPath, [cli, "test"], {
cwd: pluginDir,
stdio: "inherit",
});
if (result.status !== 0) failed.push(id);
}
if (failed.length > 0) {
console.error(`\nPlugin tests failed: ${failed.join(", ")}`);
process.exit(1);
}
console.log(`\nPlugin tests passed for ${pluginIds.length} plugin(s).`);
+7 -1
View File
@@ -14,7 +14,13 @@ const path = require("node:path");
const { pathToFileURL } = require("node:url");
const root = path.resolve(__dirname, "..");
const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js");
const sdkEntry = path.join(
root,
"packages",
"plugin-sdk",
"dist",
"manifest.js",
);
async function loadValidator() {
if (!fs.existsSync(sdkEntry)) {