feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Keeps the nginx CSP in step with the plugin import map.
*
* index.html carries an inline <script type="importmap"> so plugin bundles
* share the shell's React. The CSP only allows 'self' scripts, so the map is
* allowed by its hash. The map's text is deterministic (see
* scripts/lib/plugin-import-map.mjs), so its hash only changes when the list
* of shared modules does, e.g. when a plugin starts importing another core
* module. This fails lint until the nginx configs carry the new hash.
*
* node scripts/check-importmap-csp.cjs check
* node scripts/check-importmap-csp.cjs --write update the configs
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const CONFIGS = ["docker/nginx.conf", "docker/nginx-https.conf"];
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 write = process.argv.includes("--write");
const problems = [];
for (const relative of CONFIGS) {
const file = path.join(ROOT, relative);
const source = fs.readFileSync(file, "utf8");
let changed = false;
const next = source.replace(
new RegExp(SCRIPT_SRC.source, "g"),
(match, sources) => {
const kept = sources
.split(/\s+/)
.filter(Boolean)
.filter((token) => !token.startsWith("'sha256-"));
const updated = `script-src ${[...kept, expected].join(" ")}`;
if (updated !== match) changed = true;
return updated;
},
);
if (!SCRIPT_SRC.test(source)) {
problems.push(`${relative}: no script-src directive`);
} else if (changed) {
if (write) fs.writeFileSync(file, next);
else problems.push(`${relative}: script-src does not allow ${expected}`);
}
}
if (problems.length > 0) {
for (const problem of problems) console.error(problem);
console.error(
"The plugin import map changed. Run: node scripts/check-importmap-csp.cjs --write",
);
process.exit(1);
}
if (write) console.log(`import map CSP hash: ${expected}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+23 -5
View File
@@ -22,11 +22,21 @@ const allowlistPath = path.join(
const norm = (p) => p.split(path.sep).join("/");
const DIRECTIONS = {
// The shell importing plugin components. A7 empties this.
// 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"],
roots: ["src/ui", "src/main.tsx"],
skip: (rel) => rel.includes("/tests/"),
pattern: /["'][^"']*\/plugins\/[a-z0-9-]+\/src\/(frontend|backend)\//,
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": {
@@ -56,10 +66,16 @@ function walk(dir, out = []) {
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 walk(path.join(root, base))) {
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;
@@ -90,7 +106,9 @@ 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 added = config.mustBeEmpty
? offenders
: offenders.filter((file) => !allowed.has(file));
const stale = [...allowed].filter((file) => !offenders.includes(file));
if (added.length > 0) {
+120
View File
@@ -0,0 +1,120 @@
#!/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: host protocol
* names that are columns on the host until Phase B moves them, and the
* terminal's own websocket, which lives in core until the terminal moves.
* 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();
+141
View File
@@ -0,0 +1,141 @@
/**
* The modules the shell shares with plugin bundles, and the import map that
* points plugin imports at them.
*
* One instance of React, i18next and the SDK has to exist across the shell
* and every plugin, so plugin bundles leave these imports bare and the page's
* import map resolves them to the shell's own copies. Used by the Vite plugin
* that builds the map and by scripts/check-importmap-csp.cjs, which keeps the
* nginx CSP hash in step with it.
*/
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
* FRONTEND_EXTERNALS. `cjs` modules need their named exports listed, since
* `export *` drops them; `hasDefault` says an ES module has a default export.
*/
export const SHARED_VENDORS = [
{ specifier: "react", cjs: true },
{ specifier: "react-dom", cjs: true },
{ specifier: "react-dom/client", cjs: true },
{ specifier: "react/jsx-runtime", cjs: true },
{ specifier: "i18next", cjs: false, hasDefault: true },
{ specifier: "react-i18next", cjs: false, hasDefault: false },
{ specifier: "sonner", cjs: false, hasDefault: false },
];
export const SHARED_VENDOR_MODULES = SHARED_VENDORS.map(
(vendor) => vendor.specifier,
);
/** SDK entries that are implemented by, or hold state shared with, core. */
export const SHARED_SDK_MODULES = [
"@termix/plugin-sdk/frontend",
"@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),
];
}
/** A file-name-safe name for a shared module's shim. */
export function shimName(specifier) {
return specifier
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/** Where a shared module's stable shim lands in the build output. */
export function shimPath(specifier) {
return `shared/${shimName(specifier)}.js`;
}
/**
* The import map text for a production build. Deterministic: sorted, compact,
* and pointing at the unhashed shims, so it only changes when the list of
* shared modules does. That keeps its CSP hash stable across builds.
*/
export function productionImportMap(specifiers) {
const imports = {};
for (const specifier of [...specifiers].sort()) {
imports[specifier] = `./${shimPath(specifier)}`;
}
return JSON.stringify({ imports });
}
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;
}
+111 -17
View File
@@ -1,21 +1,115 @@
{
"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/settings/components/TailscaleDevicesStatus.tsx",
"src/ui/shell/tabUtils.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"
"ui-to-plugin": [],
"plugin-frontend-to-core": [
"plugins/ai/src/frontend/AiPanel.tsx",
"plugins/ai/src/frontend/AiProviderSettings.tsx",
"plugins/ai/src/frontend/ProposalCard.tsx",
"plugins/ai/src/frontend/ai-api.ts",
"plugins/ai/src/frontend/index.tsx",
"plugins/ai/src/frontend/settings/AiUserSettings.tsx",
"plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx",
"plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx",
"plugins/ai/src/frontend/use-ai-stream.ts",
"plugins/ai/src/frontend/useMentions.ts",
"plugins/automations/src/frontend/AutomationsPanel.tsx",
"plugins/automations/src/frontend/automations-api.ts",
"plugins/automations/src/frontend/automations/AutomationEditor.tsx",
"plugins/automations/src/frontend/automations/HostSelectorField.tsx",
"plugins/automations/src/frontend/automations/StepBlock.tsx",
"plugins/automations/src/frontend/automations/TriggerCard.tsx",
"plugins/docker/src/frontend/DockerApp.tsx",
"plugins/docker/src/frontend/DockerManager.tsx",
"plugins/docker/src/frontend/DockerWidget.tsx",
"plugins/docker/src/frontend/DockerWidgetEditForm.tsx",
"plugins/docker/src/frontend/HostDockerTab.tsx",
"plugins/docker/src/frontend/components/ConsoleTerminal.tsx",
"plugins/docker/src/frontend/components/ContainerCard.tsx",
"plugins/docker/src/frontend/components/ContainerDetail.tsx",
"plugins/docker/src/frontend/components/ContainerList.tsx",
"plugins/docker/src/frontend/components/ContainerStats.tsx",
"plugins/docker/src/frontend/components/ContainerTable.tsx",
"plugins/docker/src/frontend/components/LogViewer.tsx",
"plugins/docker/src/frontend/docker-api.ts",
"plugins/docker/src/frontend/index.tsx",
"plugins/fleets/src/frontend/FleetInventoryTab.tsx",
"plugins/fleets/src/frontend/FleetsPanel.tsx",
"plugins/fleets/src/frontend/fleets-api.ts",
"plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx",
"plugins/host-metrics/src/frontend/HostMetricsApp.tsx",
"plugins/host-metrics/src/frontend/HostMetricsTab.tsx",
"plugins/host-metrics/src/frontend/MetricsChartEditForm.tsx",
"plugins/host-metrics/src/frontend/MetricsChartWidget.tsx",
"plugins/host-metrics/src/frontend/cards/CardTimeTabs.tsx",
"plugins/host-metrics/src/frontend/cards/CpuCard.tsx",
"plugins/host-metrics/src/frontend/cards/DiskCard.tsx",
"plugins/host-metrics/src/frontend/cards/FilesystemPicker.tsx",
"plugins/host-metrics/src/frontend/cards/FirewallCard.tsx",
"plugins/host-metrics/src/frontend/cards/GpuCard.tsx",
"plugins/host-metrics/src/frontend/cards/LoginStatsCard.tsx",
"plugins/host-metrics/src/frontend/cards/MemoryCard.tsx",
"plugins/host-metrics/src/frontend/cards/NetworkCard.tsx",
"plugins/host-metrics/src/frontend/cards/PortsCard.tsx",
"plugins/host-metrics/src/frontend/cards/ProcessesCard.tsx",
"plugins/host-metrics/src/frontend/cards/SystemCard.tsx",
"plugins/host-metrics/src/frontend/cards/TemperatureCard.tsx",
"plugins/host-metrics/src/frontend/cards/UptimeCard.tsx",
"plugins/host-metrics/src/frontend/cards/gpu-history.ts",
"plugins/host-metrics/src/frontend/cards/index.tsx",
"plugins/host-metrics/src/frontend/cards/managers/CronManagerCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/FirewallManagerCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/HealthCheckCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/LogViewerCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/ManagerCardShell.tsx",
"plugins/host-metrics/src/frontend/cards/managers/PackageManagerCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/SimpleManagerCards.tsx",
"plugins/host-metrics/src/frontend/cards/managers/SslManagerCard.tsx",
"plugins/host-metrics/src/frontend/cards/managers/UserManagerCard.tsx",
"plugins/host-metrics/src/frontend/hooks/useHostMetricsPreferences.ts",
"plugins/host-metrics/src/frontend/host-metrics-api.ts",
"plugins/host-metrics/src/frontend/index.tsx",
"plugins/host-metrics/src/frontend/metrics-change-key.ts",
"plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts",
"plugins/host-metrics/src/frontend/temperature-preference.ts",
"plugins/network-topology/src/frontend/NetworkGraphCard.tsx",
"plugins/proxmox/src/frontend/HostProxmoxTab.tsx",
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",
"plugins/proxmox/src/frontend/index.tsx",
"plugins/remote-desktop/src/frontend/GuacamoleApp.tsx",
"plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx",
"plugins/remote-desktop/src/frontend/GuacamoleFileBrowser.tsx",
"plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx",
"plugins/remote-desktop/src/frontend/HostEditorGuacamoleTabs.tsx",
"plugins/remote-desktop/src/frontend/guacamole-api.ts",
"plugins/remote-desktop/src/frontend/guacamole-session-id.ts",
"plugins/remote-desktop/src/frontend/index.tsx",
"plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts",
"plugins/remote-desktop/src/frontend/rdp-credential-prompt.ts",
"plugins/ssh-terminal/src/frontend/index.tsx",
"plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx",
"plugins/tailscale/src/frontend/TailscaleCheckOverlay.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/HostWebUiTab.tsx",
"plugins/web-endpoint/src/frontend/WebEndpointTab.tsx",
"plugins/web-endpoint/src/frontend/index.tsx",
"plugins/web-endpoint/src/frontend/web-endpoint-api.ts",
"plugins/web-endpoint/src/frontend/web-endpoint-url.ts",
"plugins/web-endpoint/src/frontend/web-endpoint-validation.ts",
"plugins/workspaces/src/frontend/WorkspacesPanel.tsx",
"plugins/workspaces/src/frontend/index.tsx",
"plugins/workspaces/src/frontend/workspaces-api.ts"
],
"plugin-to-core": [
"plugins/ai/src/backend/egress.ts",
+121
View File
@@ -0,0 +1,121 @@
{
"$comment": "Literals src/ui may still spell that name a plugin id or a plugin-owned view. Each is data or the core terminal, not knowledge of a plugin. Checked by scripts/check-shell-plugin-ids.cjs; D1 and Phase B empty it.",
"files": {
"src/ui/AppShell.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/api/collab-api.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/api/session-log-api.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/api/session-sharing-api.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/contexts/ConnectionDefaultsContext.tsx": {
"literals": ["rdp", "terminal"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/features/collab/CollabRoomTab.tsx": {
"literals": ["rdp", "ssh-terminal", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it. The core terminal opens its own websocket, served by the ssh-terminal plugin, until the terminal moves into it in Phase B."
},
"src/ui/features/file-manager/components/TransferToHostDialog.tsx": {
"literals": ["rdp", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/features/homepage/widgets/QuickConnectWidget.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/features/session-sharing/SharedSessionView.tsx": {
"literals": ["ssh-terminal"],
"reason": "The core terminal opens its own websocket, served by the ssh-terminal plugin, until the terminal moves into it in Phase B."
},
"src/ui/features/sftp/SftpTransferTab.tsx": {
"literals": ["rdp", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/features/terminal/Terminal.tsx": {
"literals": ["ssh-terminal", "terminal"],
"reason": "The core terminal opens its own websocket, served by the ssh-terminal plugin, until the terminal moves into it in Phase B. The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/features/terminal/TerminalTabContent.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/features/terminal/TerminalToolbar.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/features/tmux-monitor/TmuxMonitor.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/lib/connection-origin.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/lib/host-connection-tabs.ts": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/onboarding/steps/WelcomeStep.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/shell/TabBar.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/shell/TabContext.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/ConnectionDefaultsSettings.tsx": {
"literals": ["rdp", "terminal"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/ConnectionsPanel.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/HostEditor.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/HostEditorData.ts": {
"literals": ["docker", "rdp", "telnet", "vnc"],
"reason": "Host data: the enableDocker column filter and the container runtime name, until Phase B moves them. Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/sidebar/HostManagerData.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/sidebar/HostManagerTabs.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/HostsPanel.tsx": {
"literals": ["docker", "rdp", "telnet", "terminal", "vnc"],
"reason": "Host data: the enableDocker column filter and the container runtime name, until Phase B moves them. Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it. The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/QuickConnectPanel.tsx": {
"literals": ["rdp", "terminal", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it. The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
},
"src/ui/sidebar/quick-connect-host.ts": {
"literals": ["rdp", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/sidebar/tree/HostItem/HostItem.tsx": {
"literals": ["terminal"],
"reason": "The terminal tab type. The terminal component, split view and file-manager hooks stay in core until the terminal's Phase B step; the ssh-terminal plugin only decides whether it is offered."
}
}
}
+204
View File
@@ -0,0 +1,204 @@
/**
* 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.
* - 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
* import.meta.glob calls, so the monorepo's plugins load through Vite with
* HMR, and the map points at dev-server module URLs instead.
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import {
importMapCspHash,
productionImportMap,
resolveLegacyCoreFile,
sharedModules,
shimName,
shimPath,
SHARED_VENDOR_MODULES,
SHARED_VENDORS,
} from "./lib/plugin-import-map.mjs";
const VIRTUAL_PREFIX = "\0termix-shared:";
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
function hasDefaultExport(file) {
const source = fs.readFileSync(file, "utf8");
return (
/\bexport\s+default\b/.test(source) ||
/\bexport\s*\{[^}]*\bas\s+default\b/.test(source)
);
}
/**
* CommonJS packages (React among them) lose their named exports through
* `export *`, so their facade lists every name explicitly, read from the
* package itself at build time.
*/
function vendorFacade(repoRoot, specifier) {
const vendor = SHARED_VENDORS.find((item) => item.specifier === specifier);
if (vendor && !vendor.cjs) {
const target = JSON.stringify(specifier);
return vendor.hasDefault
? `export * from ${target};
export { default } from ${target};`
: `export * from ${target};`;
}
const require = createRequire(path.join(repoRoot, "package.json"));
const names = Object.keys(require(specifier)).filter(
(name) => name !== "default" && name !== "__esModule" && IDENTIFIER.test(name),
);
const lines = [`import * as m from ${JSON.stringify(specifier)};`];
lines.push("const d = m.default ?? m;");
for (const name of names) {
lines.push(`export const ${name} = m.${name} ?? d.${name};`);
}
lines.push("export default d;");
return lines.join("\n");
}
function sourceFacade(file) {
const target = JSON.stringify(file.replaceAll("\\", "/"));
const lines = [`export * from ${target};`];
if (hasDefaultExport(file)) lines.push(`export { default } from ${target};`);
return lines.join("\n");
}
export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
let command = "build";
let shared = [];
const workspaceModule = path
.join(repoRoot, "src", "ui", "plugin-host", "workspace-plugins.ts")
.replaceAll("\\", "/");
const facadeSource = (specifier) => {
if (SHARED_VENDOR_MODULES.includes(specifier)) {
return vendorFacade(repoRoot, specifier);
}
if (specifier === "@termix/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
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);
};
return {
name: "termix-plugin-host",
config(_config, env) {
command = env.command;
shared = sharedModules(repoRoot);
if (command !== "build") return;
const input = { index: path.join(repoRoot, "index.html") };
for (const specifier of shared) {
input[`shared-${shimName(specifier)}`] = VIRTUAL_PREFIX + specifier;
}
return {
build: {
rollupOptions: {
input,
// Entry exports are the contract with plugin bundles; they must
// survive tree-shaking even though nothing in the shell uses them.
preserveEntrySignatures: "exports-only",
},
},
};
},
resolveId(id) {
if (id.startsWith(VIRTUAL_PREFIX)) return id;
return null;
},
load(id) {
if (id.startsWith(VIRTUAL_PREFIX)) {
return facadeSource(id.slice(VIRTUAL_PREFIX.length));
}
if (
command === "serve" &&
id.replaceAll("\\", "/").split("?")[0] === workspaceModule
) {
return [
'const frontends = import.meta.glob("/plugins/*/src/frontend/index.{tsx,ts}");',
'const locales = import.meta.glob("/plugins/*/locales/**/*.json", { import: "default" });',
"export const workspaceFrontends = {};",
"export const workspaceLocales = {};",
"for (const [file, load] of Object.entries(frontends)) {",
' workspaceFrontends[file.split("/")[2]] = load;',
"}",
"for (const [file, load] of Object.entries(locales)) {",
' const parts = file.split("/");',
' const id = parts[2];',
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
" (workspaceLocales[id] ??= {})[name] = load;",
"}",
].join("\n");
}
return null;
},
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || !chunk.isEntry) continue;
const facade = chunk.facadeModuleId ?? "";
if (!facade.startsWith(VIRTUAL_PREFIX)) continue;
const specifier = facade.slice(VIRTUAL_PREFIX.length);
const target = JSON.stringify(`../${chunk.fileName}`);
const lines = [`export * from ${target};`];
if (chunk.exports.includes("default")) {
lines.push(`export { default } from ${target};`);
}
this.emitFile({
type: "asset",
fileName: shimPath(specifier),
source: `${lines.join("\n")}\n`,
});
}
const text = productionImportMap(shared);
this.emitFile({
type: "asset",
fileName: "importmap.sha256",
source: `${importMapCspHash(text)}\n`,
});
},
transformIndexHtml: {
order: "pre",
handler() {
const text =
command === "build"
? productionImportMap(shared)
: JSON.stringify({
imports: Object.fromEntries(
[...shared]
.sort()
.map((specifier) => [
specifier,
`/@id/__x00__${VIRTUAL_PREFIX.slice(1)}${specifier}`,
]),
),
});
return [
{
tag: "script",
attrs: { type: "importmap" },
children: text,
injectTo: "head-prepend",
},
];
},
},
};
}