chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* Core knows no plugin by name.
*
* Fails when anything under src/ imports from plugins/, or 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"). The
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
* dashboard card id from a manifest). What core needs from a plugin comes
* through the registries instead.
*
* Tests and 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 ROOT = path.resolve(__dirname, "..");
const SRC = path.join(ROOT, "src");
const UI = path.join(SRC, "ui");
const PLUGINS = path.join(ROOT, "plugins");
const EXEMPT_DIRS = new Set(["tests", "locales"]);
const EXEMPT_PATHS = [path.join(SRC, "backend", "upgrade")];
const SSH_CONNECT = path.join(SRC, "backend", "hosts", "connect");
const SSH_TERMS = new Set(["totp"]);
function manifests() {
const dir = path.join(ROOT, "plugins");
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file))
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function names() {
const ids = new Set();
const views = new Set();
for (const manifest of manifests()) {
ids.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
contributes.tabs,
contributes.panels,
contributes.dashboardCards,
]) {
for (const view of list ?? []) views.add(view.id);
}
}
return { ids, views };
}
function walk(dir, out) {
if (EXEMPT_PATHS.includes(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXEMPT_DIRS.has(entry.name)) continue;
walk(full, 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;
}
function scan() {
const { ids, views } = names();
const found = {};
const add = (file, what) => {
const key = path.relative(ROOT, file).replaceAll("\\", "/");
(found[key] ??= new Set()).add(what);
};
for (const file of walk(SRC, [])) {
const source = fs.readFileSync(file, "utf8");
const inShell = file.startsWith(UI + path.sep);
const inConnect = file.startsWith(SSH_CONNECT + path.sep);
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
const target = match[1].startsWith(".")
? path.resolve(path.dirname(file), match[1])
: "";
if (target.startsWith(PLUGINS + path.sep)) {
add(file, `import ${match[1]}`);
}
}
for (const text of literals(source)) {
const sshTerm = inConnect && SSH_TERMS.has(text);
if ((ids.has(text) && !sshTerm) || (inShell && views.has(text))) {
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);
}
}
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);
}
}
main();
+1 -1
View File
@@ -23,7 +23,7 @@ const SCRIPT_SRC = /script-src ([^;"]*)/;
async function main() {
const { sharedModules, productionImportMap, importMapCspHash } =
await import("./lib/plugin-import-map.mjs");
const expected = importMapCspHash(productionImportMap(sharedModules(ROOT)));
const expected = importMapCspHash(productionImportMap(sharedModules()));
const write = process.argv.includes("--write");
const problems = [];
-138
View File
@@ -1,138 +0,0 @@
/**
* 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 code. Empty since A7, and kept at zero: any
// entry here fails, allowlisted or not.
"ui-to-plugin": {
roots: ["src/ui", "src/main.tsx"],
skip: (rel) => rel.includes("/tests/"),
pattern: /["'][^"']*\/plugins\/[a-z0-9-]+\//,
mustBeEmpty: true,
},
// A bundled plugin frontend importing the shell's own modules through the
// "@/" alias, which the CLI turns into @termix/legacy-core/* for the import
// map. D1 empties this.
"plugin-frontend-to-core": {
roots: ["plugins"],
only: (rel) => /^plugins\/[a-z0-9-]+\/src\/frontend\//.test(rel),
pattern: /(?:from|import)\s*\(?\s*["']@\//,
},
// 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. Empty, and stays that way.
"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 filesUnder(base) {
const full = path.join(root, base);
if (fs.existsSync(full) && fs.statSync(full).isFile()) return [full];
return walk(full);
}
function offendersFor(name, config) {
const found = new Set();
for (const base of config.roots) {
for (const file of filesUnder(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 = config.mustBeEmpty
? offenders
: 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);
}
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env node
/**
* The shell knows no plugin by name.
*
* Fails when anything under src/ui (tests and locales aside) imports from
* plugins/, or spells a plugin id or a view a plugin owns (a tab, panel or
* dashboard card id from a manifest) as a string literal. What the shell
* needs from a plugin comes through the registries instead.
*
* A few literals are data rather than knowledge of a plugin, such as a fixed
* button next to Quick Connect.
* Those are listed with a reason in scripts/shell-plugin-id-allowlist.json,
* which fails too when an entry stops being needed, so it only shrinks.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const UI = path.join(ROOT, "src", "ui");
const ALLOWLIST = path.join(__dirname, "shell-plugin-id-allowlist.json");
function manifests() {
const dir = path.join(ROOT, "plugins");
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file))
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function pluginNames() {
const names = new Set();
for (const manifest of manifests()) {
names.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
contributes.tabs,
contributes.panels,
contributes.dashboardCards,
]) {
for (const view of list ?? []) names.add(view.id);
}
}
return names;
}
function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "tests" || entry.name === "locales") continue;
walk(full, out);
} else if (/\.(tsx?|mjs|jsx?)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
function scan() {
const names = pluginNames();
const found = {};
const add = (file, what) => {
const key = path.relative(ROOT, file).replaceAll("\\", "/");
(found[key] ??= new Set()).add(what);
};
for (const file of walk(UI, [])) {
const source = fs.readFileSync(file, "utf8");
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
if (/(^|\/)plugins\//.test(match[1])) add(file, `import ${match[1]}`);
}
for (const match of source.matchAll(/["'`]([a-z][a-z0-9_-]*)["'`]/g)) {
if (names.has(match[1])) add(file, match[1]);
}
}
return Object.fromEntries(
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
);
}
function main() {
const found = scan();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(found, null, 2));
return;
}
const allow = JSON.parse(fs.readFileSync(ALLOWLIST, "utf8")).files ?? {};
const problems = [];
for (const [file, literals] of Object.entries(found)) {
const allowed = new Set(allow[file]?.literals ?? []);
for (const literal of literals) {
if (!allowed.has(literal)) problems.push(`${file}: ${literal}`);
}
}
for (const [file, entry] of Object.entries(allow)) {
for (const literal of entry.literals ?? []) {
if (!found[file]?.includes(literal)) {
problems.push(
`${file}: allowlisted "${literal}" no longer appears; remove it`,
);
}
}
}
if (problems.length > 0) {
console.error(
"The shell names plugins it should reach through a registry:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
}
main();
+2 -75
View File
@@ -10,12 +10,6 @@
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
LEGACY_CORE_PREFIX,
findLegacyCoreImports,
} from "../../packages/plugin-sdk/cli/lib/legacy-core-specifier.mjs";
/**
* Third-party modules that must be one instance. Keep in step with the CLI's
@@ -42,59 +36,8 @@ export const SHARED_SDK_MODULES = [
"@termix/plugin-sdk/ui",
];
function walk(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "node_modules" && entry.name !== "dist") {
walk(full, out);
}
} else if (/\.(tsx?|mjs|jsx?)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
/** Legacy core modules imported by any bundled plugin's frontend. */
export function scanLegacyCoreModules(repoRoot) {
const found = new Set();
const pluginsDir = path.join(repoRoot, "plugins");
let plugins = [];
try {
plugins = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
return [];
}
for (const plugin of plugins) {
if (!plugin.isDirectory()) continue;
const files = walk(
path.join(pluginsDir, plugin.name, "src", "frontend"),
[],
);
for (const file of files) {
for (const specifier of findLegacyCoreImports(
fs.readFileSync(file, "utf8"),
)) {
found.add(specifier);
}
}
}
return [...found].sort();
}
export function sharedModules(repoRoot) {
return [
...SHARED_VENDOR_MODULES,
...SHARED_SDK_MODULES,
...scanLegacyCoreModules(repoRoot),
];
export function sharedModules() {
return [...SHARED_VENDOR_MODULES, ...SHARED_SDK_MODULES];
}
/** A file-name-safe name for a shared module's shim. */
@@ -126,19 +69,3 @@ export function productionImportMap(specifiers) {
export function importMapCspHash(text) {
return `'sha256-${crypto.createHash("sha256").update(text, "utf8").digest("base64")}'`;
}
/** Absolute source file behind a legacy core specifier, or null. */
export function resolveLegacyCoreFile(repoRoot, specifier) {
if (!specifier.startsWith(LEGACY_CORE_PREFIX)) return null;
const rest = specifier.slice(LEGACY_CORE_PREFIX.length);
const base = rest.startsWith("types")
? path.join(repoRoot, "src", rest)
: path.join(repoRoot, "src", "ui", rest.slice("ui/".length));
const candidates = [
`${base}.ts`,
`${base}.tsx`,
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
];
return candidates.find((file) => fs.existsSync(file)) ?? null;
}
-62
View File
@@ -1,62 +0,0 @@
{
"ui-to-plugin": [],
"plugin-frontend-to-core": [
"plugins/file-manager/src/frontend/FileManager.tsx",
"plugins/file-manager/src/frontend/FileManagerContextMenu.tsx",
"plugins/file-manager/src/frontend/FileManagerDialogs.tsx",
"plugins/file-manager/src/frontend/FileManagerGrid.tsx",
"plugins/file-manager/src/frontend/FileManagerSidebar.tsx",
"plugins/file-manager/src/frontend/FileManagerToolbar.tsx",
"plugins/file-manager/src/frontend/LocalFileContextMenu.tsx",
"plugins/file-manager/src/frontend/LocalFilePane.tsx",
"plugins/file-manager/src/frontend/SftpTransferTab.tsx",
"plugins/file-manager/src/frontend/TransferMonitor.tsx",
"plugins/file-manager/src/frontend/api/file-manager-data-api.ts",
"plugins/file-manager/src/frontend/api/local-transfer-api.ts",
"plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts",
"plugins/file-manager/src/frontend/components/AudioPreview.tsx",
"plugins/file-manager/src/frontend/components/ColumnResizeHandle.tsx",
"plugins/file-manager/src/frontend/components/ColumnVisibilityMenu.tsx",
"plugins/file-manager/src/frontend/components/DiffViewer.tsx",
"plugins/file-manager/src/frontend/components/DiffWindow.tsx",
"plugins/file-manager/src/frontend/components/DownloadProgressToast.tsx",
"plugins/file-manager/src/frontend/components/DraggableWindow.tsx",
"plugins/file-manager/src/frontend/components/FileViewer.tsx",
"plugins/file-manager/src/frontend/components/FileWindow.tsx",
"plugins/file-manager/src/frontend/components/PaneResizeHandle.tsx",
"plugins/file-manager/src/frontend/components/TerminalWindow.tsx",
"plugins/file-manager/src/frontend/components/TransferProgressBar.tsx",
"plugins/file-manager/src/frontend/components/TransferProgressToast.tsx",
"plugins/file-manager/src/frontend/components/TransferToHostDialog.tsx",
"plugins/file-manager/src/frontend/file-manager-types.ts",
"plugins/file-manager/src/frontend/file-preview-preload.ts",
"plugins/file-manager/src/frontend/homepage/FileManagerWidget.tsx",
"plugins/file-manager/src/frontend/hooks/useDragToDesktop.ts",
"plugins/file-manager/src/frontend/hooks/useDragToSystemDesktop.ts",
"plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts",
"plugins/file-manager/src/frontend/lib/file-content-request-cache.ts",
"plugins/file-manager/src/frontend/lib/file-list-request-cache.ts",
"plugins/file-manager/src/frontend/local-transfer-utils.ts",
"plugins/file-manager/src/frontend/optimistic-file-list.ts",
"plugins/file-manager/src/frontend/transferMetricsFormat.ts",
"plugins/file-manager/src/frontend/transferProgressMonitor.tsx",
"plugins/proxmox/src/frontend/HostProxmoxTab.tsx",
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",
"plugins/proxmox/src/frontend/index.tsx",
"plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx",
"plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx",
"plugins/tailscale/src/frontend/TailscaleDevicesStatus.tsx",
"plugins/tailscale/src/frontend/index.tsx",
"plugins/tailscale/src/frontend/tailscale-api.ts",
"plugins/web-endpoint/src/frontend/HostEditorWebUiSection.tsx",
"plugins/web-endpoint/src/frontend/WebEndpointTab.tsx",
"plugins/web-endpoint/src/frontend/index.tsx",
"plugins/web-endpoint/src/frontend/web-endpoint-api.ts"
],
"plugin-to-core": [
"plugins/file-manager/src/backend/index.ts",
"plugins/file-manager/src/backend/operation-routes.ts",
"plugins/tailscale/src/backend/routes.ts"
],
"plugin-to-plugin": []
}
-9
View File
@@ -1,9 +0,0 @@
{
"$comment": "Literals src/ui may still spell that name a plugin id or a plugin-owned view. Each is data, not knowledge of a plugin. Checked by scripts/check-shell-plugin-ids.cjs, which also fails when an entry stops being needed.",
"files": {
"src/ui/sidebar/QuickConnectPanel.tsx": {
"literals": ["files"],
"reason": "The quick-connect-for-an-unsaved-host flow offers a fixed Files button next to the default connect button, not an extensible list a plugin contributes to."
}
}
}
+4 -12
View File
@@ -1,9 +1,8 @@
/**
* Makes the shell a host for plugin bundles.
*
* - Every shared module (React, i18next, the SDK, and the legacy core modules
* bundled plugins still import) becomes an entry chunk, reached through a
* stable shim at dist/shared/<name>.js.
* - Every shared module (React, i18next and the SDK) becomes an entry chunk,
* reached through a stable shim at dist/shared/<name>.js.
* - index.html gets an import map pointing those bare specifiers at the shims,
* so a plugin bundle's `import "react"` lands on the shell's React.
* - In `vite dev`, src/ui/plugin-host/workspace-plugins.ts is replaced with
@@ -17,7 +16,6 @@ import { createRequire } from "node:module";
import {
importMapCspHash,
productionImportMap,
resolveLegacyCoreFile,
sharedModules,
shimName,
shimPath,
@@ -86,13 +84,7 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
if (specifier === "@termix/plugin-sdk/frontend") {
return sourceFacade(sdkFrontendEntry);
}
const file = resolveLegacyCoreFile(repoRoot, specifier);
if (!file) {
throw new Error(
`termix-plugin-host: a plugin imports ${specifier}, which does not exist in core`,
);
}
return sourceFacade(file);
throw new Error(`termix-plugin-host: ${specifier} is not a shared module`);
};
return {
@@ -100,7 +92,7 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
config(_config, env) {
command = env.command;
shared = sharedModules(repoRoot);
shared = sharedModules();
if (command !== "build") return;
const input = { index: path.join(repoRoot, "index.html") };
for (const specifier of shared) {