mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user