mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
chore: move plugins to their own repos and drop the plugins folder
This commit is contained in:
1 parent
29a946d02e
commit
e02b5fe6e4
3167 files changed
+286
-440749
No files matched your search
@@ -43,9 +43,6 @@ export async function updateSessionTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
// Tailscale settings/device API wrappers moved to
|
||||
// plugins/tailscale/src/frontend/tailscale-api.ts.
|
||||
|
||||
// ============================================================================
|
||||
// BRANDING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
setFrontendState,
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins";
|
||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
||||
|
||||
/**
|
||||
@@ -75,16 +74,12 @@ const defaultDeps: PluginLoaderDeps = {
|
||||
fetchPlugins: getPlugins,
|
||||
|
||||
async importFrontend(summary) {
|
||||
const workspace = workspaceFrontends[summary.id];
|
||||
if (workspace) return (await workspace()) as FrontendModule;
|
||||
return (await import(
|
||||
/* @vite-ignore */ absolute(assetUrl(summary, "frontend.js"))
|
||||
)) as FrontendModule;
|
||||
},
|
||||
|
||||
async loadLocale(summary, file) {
|
||||
const workspace = workspaceLocales[summary.id]?.[file];
|
||||
if (workspace) return (await workspace()) as ResourceKey;
|
||||
const hasFile = summary.locales?.includes(file);
|
||||
if (!hasFile) return null;
|
||||
const path = file === "en" ? "en.json" : `translated/${file}.json`;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Plugin sources in this monorepo, for the Vite dev server.
|
||||
*
|
||||
* Empty everywhere except `vite dev`, where the termix-plugin-host Vite plugin
|
||||
* replaces this module with import.meta.glob calls over plugins/<id>/, so
|
||||
* plugin frontends load through Vite with HMR instead of from their built
|
||||
* bundles. A production build keeps it empty: plugins are separate bundles
|
||||
* served from /plugin-assets, never part of the shell's own build.
|
||||
*/
|
||||
|
||||
type Loader = () => Promise<unknown>;
|
||||
|
||||
/** plugin id -> its src/frontend/index.tsx */
|
||||
export const workspaceFrontends: Record<string, Loader> = {};
|
||||
|
||||
/** plugin id -> locale file ("en", "de_DE") -> JSON */
|
||||
export const workspaceLocales: Record<string, Record<string, Loader>> = {};
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Every built plugin frontend in dist/plugins imports through the real loader
|
||||
* and runs activate(app) without an error. Needs `npm run build` first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
configurePluginLoader,
|
||||
resetPluginLoader,
|
||||
syncPlugins,
|
||||
} from "@/plugin-host/loader";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const BUILT = path.join(REPO_ROOT, "dist", "plugins");
|
||||
|
||||
function pluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function manifest(id: string) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT, id, "manifest.json"), "utf8"),
|
||||
) as {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
icon?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
contributes?: PluginSummary["contributes"];
|
||||
};
|
||||
}
|
||||
|
||||
function frontendFile(id: string): string {
|
||||
return path.join(BUILT, id, "dist", "frontend.js");
|
||||
}
|
||||
|
||||
/** What GET /plugins would answer for a running, enabled plugin. */
|
||||
function summaryFor(id: string): PluginSummary {
|
||||
const m = manifest(id);
|
||||
return {
|
||||
id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: m.contributes ?? null,
|
||||
icon: m.icon,
|
||||
dependencies: m.dependencies ?? {},
|
||||
optionalDependencies: m.optionalDependencies ?? {},
|
||||
frontend: fs.existsSync(frontendFile(id)),
|
||||
assetVersion: "test",
|
||||
locales: ["en"],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: unknown[][] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const missing = pluginIds().filter(
|
||||
(id) => !fs.existsSync(path.join(BUILT, id, "manifest.json")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing here talks to a server; a plugin that fetches on activate gets
|
||||
// an empty answer rather than a network error.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 200 })),
|
||||
);
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args);
|
||||
});
|
||||
|
||||
configurePluginLoader({
|
||||
fetchPlugins: async () => pluginIds().map(summaryFor),
|
||||
importFrontend: async (summary) =>
|
||||
(await import(
|
||||
/* @vite-ignore */ pathToFileURL(frontendFile(summary.id)).href
|
||||
)) as FrontendModule,
|
||||
loadLocale: async (summary, file) => {
|
||||
if (file !== "en") return null;
|
||||
const locale = path.join(BUILT, summary.id, "locales", "en.json");
|
||||
return fs.existsSync(locale)
|
||||
? JSON.parse(fs.readFileSync(locale, "utf8"))
|
||||
: null;
|
||||
},
|
||||
injectCss: () => null,
|
||||
});
|
||||
|
||||
await syncPlugins();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await resetPluginLoader();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("built plugin frontends", () => {
|
||||
it("activates every plugin that ships a frontend", () => {
|
||||
const notActive = pluginIds()
|
||||
.filter((id) => fs.existsSync(frontendFile(id)))
|
||||
.map((id) => [
|
||||
id,
|
||||
getPluginRecord(id)?.frontend,
|
||||
getPluginRecord(id)?.error,
|
||||
])
|
||||
.filter(([, state]) => state !== "active");
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("logs no error while importing and activating them", () => {
|
||||
// Axios has no server to reach here; a failed request is the test
|
||||
// environment, not the plugin.
|
||||
const real = errors
|
||||
.map((args) => args.map(String).join(" "))
|
||||
.filter((line) => !/Network Error/.test(line));
|
||||
expect(real).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,48 @@ import type { PluginSummary } from "@/api/plugins-api";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
import recording from "../../../../plugins/session-recording/manifest.json";
|
||||
import sharing from "../../../../plugins/session-sharing/manifest.json";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
const recording = {
|
||||
id: "session-recording",
|
||||
name: "Session Recording",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
enableKey: "enableSessionRecording",
|
||||
enableLabelKey: "settings.host.enable.label",
|
||||
enableDescriptionKey: "settings.host.enable.description",
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sharing = {
|
||||
id: "session-sharing",
|
||||
name: "Session Sharing",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "allowSessionSharing",
|
||||
type: "boolean",
|
||||
labelKey: "settings.host.allowSessionSharing.label",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe.each([
|
||||
{ manifest: recording, key: "enableSessionRecording" },
|
||||
{ manifest: sharing, key: "allowSessionSharing" },
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { PluginSummary } from "@/api/plugins-api";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
import manifest from "../../../../plugins/session-sharing/manifest.json";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -12,6 +11,26 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const manifest = {
|
||||
id: "session-sharing",
|
||||
name: "Session Sharing",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "allowSessionSharing",
|
||||
type: "boolean",
|
||||
labelKey: "settings.host.allowSessionSharing.label",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("host sharing settings on reopening the editor", () => {
|
||||
it.each([false, true])("retains the saved sharing value %s", (saved) => {
|
||||
const pluginSettings = {
|
||||
|
||||
Reference in new issue
Block a user