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
@@ -29,7 +29,8 @@ dependencies. Entry points:
|
||||
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
|
||||
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
|
||||
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
|
||||
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
|
||||
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
|
||||
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
|
||||
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
||||
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
||||
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
||||
@@ -271,11 +272,145 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
||||
|
||||
### 8. UI
|
||||
|
||||
A plugin frontend is a built ESM bundle loaded at runtime by the core loader.
|
||||
It registers everything through the `app` object: rail items, panels, tabs,
|
||||
host editor sections, host actions and badges, context menu items, palette
|
||||
entries, dashboard cards, homepage widgets, settings components, action slots,
|
||||
login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.**
|
||||
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
|
||||
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
|
||||
no imports from `plugins/`; everything a plugin shows goes through the `app`
|
||||
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
|
||||
plugin id in `src/ui` outside tests, apart from the entries in
|
||||
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host protocol
|
||||
data like `rdp`, which Phase B moves).
|
||||
|
||||
#### The loader
|
||||
|
||||
`src/ui/plugin-host/loader.ts` runs after login and again whenever plugin
|
||||
state may have changed: the `termix:plugins-changed` window event (the admin
|
||||
toggle fires it) and window focus, throttled to 30 seconds. There is no push
|
||||
channel yet. Each pass:
|
||||
|
||||
1. Fetches `GET /plugins` (`frontend`, `css`, `assetVersion`, `locales`,
|
||||
`dependencies` and `contributes` per plugin).
|
||||
2. Loads every plugin's locales into the i18next namespace `<id>`, enabled or
|
||||
not, because the role editor and the plugin list need disabled plugins'
|
||||
titles.
|
||||
3. Orders enabled plugins by dependencies. A plugin whose hard dependency is
|
||||
missing or off is skipped.
|
||||
4. For each plugin with a frontend: injects `frontend.css`, imports
|
||||
`/plugin-assets/<id>/frontend.js?v=<assetVersion>` and calls `activate(app)`.
|
||||
In dev the workspace plugins are imported from
|
||||
`plugins/<id>/src/frontend/index.tsx` through Vite, so HMR works.
|
||||
5. Deactivates plugins that went away: runs `deactivate()` then every
|
||||
disposer. No reload.
|
||||
|
||||
A plugin that throws while importing or activating is marked failed, its
|
||||
partial registrations are disposed, and the rest of the app carries on. The
|
||||
same `assetVersion` is not retried until it changes.
|
||||
|
||||
Anonymous shared-session and collab pages have no session. They load only
|
||||
plugins whose manifest sets `contributes.guest: true`, from the public
|
||||
`GET /plugins/public`, and `app.guest` is true there.
|
||||
|
||||
#### Shared modules and the import map
|
||||
|
||||
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
|
||||
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's
|
||||
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
|
||||
shared module, writes a stable unhashed shim for each at
|
||||
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping
|
||||
the bare names to those shims. In dev the map points at Vite's virtual
|
||||
modules instead. The map is inline, so both nginx configs allow it by hash in
|
||||
`script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the
|
||||
hash is out of date and `--write` updates it. The map changes only when the
|
||||
list of shared modules does, which includes the legacy-core modules plugins
|
||||
import.
|
||||
|
||||
Electron loads `index.html` from `file://`. The map uses relative URLs, and
|
||||
plugin bundles come from the embedded backend
|
||||
(`http://localhost:30001/plugin-assets/...`), which is why `/plugin-assets`
|
||||
sends `Access-Control-Allow-Origin: *`.
|
||||
|
||||
#### Serving bundles
|
||||
|
||||
`GET /plugin-assets/<id>/<path>` serves the directory holding a plugin's
|
||||
frontend bundle and its `locales/` directory, nothing else: never
|
||||
`backend.js`, never a path outside those two, only `.js`, `.css`, `.json` and
|
||||
`.map`. It is unauthenticated like `/assets`, since `import()` cannot send the
|
||||
bearer header and bundles are not secret. With `?v=` the response is cached
|
||||
as immutable; without it, `no-cache`. nginx has a `^~ /plugin-assets/` block.
|
||||
|
||||
#### The app object
|
||||
|
||||
Every `register*` call returns a disposer and is also disposed automatically
|
||||
when the plugin deactivates. Every registered component is wrapped in the
|
||||
plugin's scope (so the hooks know which plugin they belong to), an error
|
||||
boundary and Suspense.
|
||||
|
||||
| Member | What it does |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `registerRailItem` | A rail button. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
|
||||
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
|
||||
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
|
||||
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
|
||||
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
|
||||
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
|
||||
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Typed now, wired by A8 |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
|
||||
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
|
||||
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
|
||||
`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`. The SDK
|
||||
has no runtime dependencies: the hooks delegate to a host bridge core installs.
|
||||
|
||||
Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
|
||||
(declared by ssh-terminal), `onboarding.steps`, `onboarding.features`,
|
||||
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
|
||||
and `session.remoteDisplay`. Cross-plugin frontend calls go through actions:
|
||||
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
|
||||
|
||||
#### View ownership
|
||||
|
||||
Who owns a tab, panel or card comes from manifests, so it is known even for a
|
||||
disabled plugin whose code never loaded. A saved tab or dashboard card whose
|
||||
plugin is disabled, failed, still loading or not installed renders "This
|
||||
needs the <name> plugin" (`PluginViewPlaceholder`) and is never dropped from
|
||||
the saved layout. Tab restore waits for the first plugin pass, so a restored
|
||||
tab does not race its plugin.
|
||||
|
||||
#### The `ui` entry
|
||||
|
||||
`@termix/plugin-sdk/ui` is public API, implemented by
|
||||
`src/ui/plugin-host/sdk-ui.ts`. Adding to it is a contract change; removing
|
||||
from it is a breaking one. Today it exports: alert, alert-dialog, badge,
|
||||
button, card, checkbox, dialog, dropdown-menu, input, label, password-input,
|
||||
select, select2, separator, switch, textarea, tooltip, section-card,
|
||||
metric-card, charts, the card grid, `ConnectionScreen` and the connection
|
||||
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
|
||||
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
|
||||
`useTabs`/`useTabsSafe`, `ActionSlot` and `ComponentSlot`. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
|
||||
#### Strings
|
||||
|
||||
A plugin's strings live in `plugins/<id>/locales/en.json` and load into the
|
||||
`<id>` namespace, with core's `translation` as the fallback, so a plugin can
|
||||
still use `common.*`. Manifest keys (`titleKey`, `labelKey`) resolve in the
|
||||
plugin namespace; `pluginKey(id, key)` in core returns `<id>:<key>`.
|
||||
Translations go to `plugins/<id>/locales/translated/<xx_YY>.json`, which is
|
||||
where Crowdin writes them. Only `en.json` is edited by hand.
|
||||
|
||||
#### Type checking
|
||||
|
||||
`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests
|
||||
with bundler resolution, and is part of `npm run type-check`.
|
||||
|
||||
### 9. Cross-plugin use
|
||||
|
||||
@@ -314,8 +449,12 @@ capability, registry, service, secret and shutdown suites.
|
||||
|
||||
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
|
||||
capabilities the way the runtime does, `createFakeContext()` for tests that do
|
||||
not care about the gates, and `renderWithApp()` for frontend tests (**A7**
|
||||
finishes it).
|
||||
not care about the gates, and `renderWithApp(plugin, options)` for frontend
|
||||
tests. `renderWithApp` activates the plugin against core's real registries,
|
||||
records what it registered and the shell calls it made, renders any tab,
|
||||
panel, card, host editor section, settings component or slot, and
|
||||
`deactivate()` disposes it all. Every bundled plugin has a
|
||||
`tests/frontend/activate.test.tsx` built on it.
|
||||
|
||||
### 12. Auth
|
||||
|
||||
@@ -339,8 +478,8 @@ plugins/<id>/
|
||||
tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json
|
||||
vitest.config.ts one line, the SDK preset
|
||||
src/backend/index.ts exports activate(ctx) and deactivate()
|
||||
src/frontend/index.tsx exports activate(app) and deactivate() (A7)
|
||||
locales/en.json English strings; Crowdin translates the rest
|
||||
src/frontend/index.tsx exports activate(app) and deactivate()
|
||||
locales/en.json English strings; Crowdin writes locales/translated/
|
||||
migrations/{sqlite,postgres,mysql}/ owned tables, one .sql per dialect
|
||||
migrations/snapshot.json diff basis for the generator
|
||||
src/backend/tables.ts defineTable() definitions
|
||||
@@ -545,17 +684,23 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
||||
appear in the plugin's own `contributes.permissions`. A dotted id is taken as
|
||||
given, because the validator cannot know which other plugins exist.
|
||||
|
||||
`contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey,
|
||||
icon? }`) declare the views a plugin owns besides its tabs; the app object
|
||||
refuses to register a view the manifest does not declare. `contributes.guest`
|
||||
opts a plugin into anonymous guest pages. Action contributions and slots
|
||||
accept the kinds `button` and `component`.
|
||||
|
||||
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
|
||||
`requiresSecret` are carried forward from v1 unchanged because the service and
|
||||
secret registries read them at activation. A6 and A7 reshape them.
|
||||
secret registries read them at activation. Phase B reshapes them.
|
||||
|
||||
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
|
||||
by `contributes.permissions`. A plugin no longer picks its own group name.
|
||||
|
||||
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
|
||||
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
|
||||
`backend`/`frontend`/`platforms` fields), `contributes.dashboardCards`,
|
||||
`contributes.settingsPanel`, `contributes.apiPrefix`, and
|
||||
`backend`/`frontend`/`platforms` fields), the v1 shape of
|
||||
`contributes.dashboardCards`, `contributes.settingsPanel`, `contributes.apiPrefix`, and
|
||||
`process:transport-owner` (the marker for the tier that no longer exists).
|
||||
|
||||
---
|
||||
@@ -767,11 +912,12 @@ and that is bundled into its output.
|
||||
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
|
||||
`@anthropic-ai/sdk`, `drizzle-orm`.
|
||||
|
||||
**Frontend**: `react`, `react-dom`, `react/jsx-runtime`, `@termix/plugin-sdk`,
|
||||
`i18next`, `react-i18next`, and the UI libraries core provides today
|
||||
(`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`,
|
||||
`guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these
|
||||
through the SDK `ui` entry.
|
||||
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
|
||||
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the
|
||||
import map (see UI). Everything else, including `lucide-react`, `axios`,
|
||||
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
|
||||
it.
|
||||
|
||||
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
|
||||
|
||||
@@ -787,6 +933,13 @@ and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
so a source file at any nesting collapses to the same prefix. D1 deletes that
|
||||
file.
|
||||
|
||||
Frontends do the same with `@/`: in the browser build, `@/x` and
|
||||
`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or
|
||||
`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared
|
||||
module so plugin and shell use one instance of each core module. The CLI
|
||||
refuses the rewrite outside a Termix checkout. Every such import is counted
|
||||
in the `plugin-frontend-to-core` direction below.
|
||||
|
||||
This is expected and temporary. The SDK does not yet expose what they need:
|
||||
the SSH connection pool, host resolution, the repository layer, the shared
|
||||
component library. D1 removes the debt once A3 through A8 have given them
|
||||
@@ -798,7 +951,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| ------------------------------------------------ | --------- | --------- | ---------- |
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin components | Warning | 16 files | A7 |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 109 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 70 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
|
||||
@@ -831,5 +985,9 @@ Known specifics:
|
||||
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
|
||||
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
|
||||
a port any more (A4).
|
||||
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
|
||||
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
|
||||
- The terminal component and `TerminalTabContent` stay in core until the
|
||||
terminal's Phase B step (session manager, split view and file-manager
|
||||
callbacks). ssh-terminal registers them through the legacy alias.
|
||||
- Host data columns (`enableDocker`, `enableRdp`, `guacamoleConfig` and so on)
|
||||
stay in core types until Phase B moves them. Only UI branches on them left
|
||||
the shell.
|
||||
@@ -17,6 +17,17 @@ const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.js",
|
||||
];
|
||||
|
||||
/** Whether the plugin sits in a Termix checkout, i.e. is a bundled plugin. */
|
||||
function insideTermixCheckout(cwd) {
|
||||
let dir = path.resolve(cwd);
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, "src", "ui", "plugin-host"))) return true;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return false;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
@@ -58,7 +69,13 @@ export async function build({ cwd }) {
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
plugins: [legacyCoreImports({ pluginId, platform: "browser" })],
|
||||
plugins: [
|
||||
legacyCoreImports({
|
||||
pluginId,
|
||||
platform: "browser",
|
||||
insideTermix: insideTermixCheckout(cwd),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,24 +25,21 @@ export const BACKEND_EXTERNALS = [
|
||||
"drizzle-orm/*",
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolved by the page's import map to the shell's own copies, because these
|
||||
* must be one instance: React, i18next, the toast store, and the SDK entries
|
||||
* that share state with core. Anything else a plugin imports is bundled.
|
||||
*/
|
||||
export const FRONTEND_EXTERNALS = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react-dom/client",
|
||||
"@termix/plugin-sdk",
|
||||
"@termix/plugin-sdk/*",
|
||||
"i18next",
|
||||
"react-i18next",
|
||||
// Core-provided UI libraries. A7 serves these through the SDK ui entry.
|
||||
"lucide-react",
|
||||
"sonner",
|
||||
"axios",
|
||||
"cytoscape",
|
||||
"react-cytoscapejs",
|
||||
"guacamole-common-js",
|
||||
"react-xtermjs",
|
||||
"@xterm/*",
|
||||
// Legacy: the shell's own modules, still reached by alias. A7 removes.
|
||||
"@/*",
|
||||
"@termix/plugin-sdk/frontend",
|
||||
"@termix/plugin-sdk/ui",
|
||||
// Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes.
|
||||
"@termix/legacy-core/*",
|
||||
];
|
||||
@@ -12,6 +12,8 @@
|
||||
* Delete this file in D1, once every plugin reaches core through the SDK.
|
||||
*/
|
||||
|
||||
import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs";
|
||||
|
||||
// "../../../src/backend/" or "../../../../src/backend/", any depth.
|
||||
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
||||
// Shared types. Mostly erased at compile, but automations imports a real
|
||||
@@ -27,10 +29,36 @@ const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
|
||||
const COMPILED_CORE = "../../../backend/backend/";
|
||||
const COMPILED_TYPES = "../../../backend/types/";
|
||||
|
||||
export function legacyCoreImports({ pluginId, platform }) {
|
||||
const CORE_ALIAS = /^@\//;
|
||||
|
||||
export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
|
||||
return {
|
||||
name: "legacy-core-imports",
|
||||
setup(build) {
|
||||
// The shell's own modules, reached as "@/..." or by relative path.
|
||||
// Bundling them would give the plugin a second copy of the shell's
|
||||
// contexts and API client, so each becomes a bare specifier the page's
|
||||
// import map resolves to the shell's own module.
|
||||
if (platform === "browser") {
|
||||
const toLegacy = (args) => {
|
||||
const specifier = legacyCoreSpecifier(args.path);
|
||||
if (!specifier) return null;
|
||||
if (!insideTermix) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { path: specifier, external: true };
|
||||
};
|
||||
build.onResolve({ filter: CORE_ALIAS }, toLegacy);
|
||||
build.onResolve({ filter: CORE_UI }, toLegacy);
|
||||
build.onResolve({ filter: CORE_TYPES }, toLegacy);
|
||||
}
|
||||
|
||||
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
|
||||
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
|
||||
external: true,
|
||||
@@ -41,16 +69,6 @@ export function legacyCoreImports({ pluginId, platform }) {
|
||||
external: true,
|
||||
}));
|
||||
|
||||
// The shell's own modules. Only plugins/docker's frontend entry reaches
|
||||
// core this way; every other frontend uses the "@/" alias, which is
|
||||
// externalized by name. A7 removes both.
|
||||
if (platform === "browser") {
|
||||
build.onResolve({ filter: CORE_UI }, (args) => ({
|
||||
path: args.path,
|
||||
external: true,
|
||||
}));
|
||||
}
|
||||
|
||||
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
|
||||
const [, other] = CROSS_PLUGIN.exec(args.path);
|
||||
if (other === "src" || other === pluginId) return null;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Legacy core modules a bundled plugin frontend still imports.
|
||||
*
|
||||
* Bundled plugin frontends predate the SDK and import the shell's own modules
|
||||
* as "@/..." (or, in a couple of places, by relative path into src/). Bundling
|
||||
* those would give the plugin a second copy of the shell's contexts and API
|
||||
* client, so the CLI rewrites each one to a bare specifier under
|
||||
* "@termix/legacy-core/" and core's build exposes exactly that module through
|
||||
* the import map. Both sides use this one normalizer so they cannot disagree.
|
||||
*
|
||||
* Only plugins built inside a Termix checkout may do this. D1 deletes it.
|
||||
*/
|
||||
|
||||
export const LEGACY_CORE_PREFIX = "@termix/legacy-core/";
|
||||
|
||||
const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/;
|
||||
|
||||
function clean(rest) {
|
||||
return rest.replace(EXTENSION, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button"
|
||||
* "@/types" -> "@termix/legacy-core/types"
|
||||
* "@/types/connection-log" -> "@termix/legacy-core/types/connection-log"
|
||||
* "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x"
|
||||
* Anything else -> null
|
||||
*/
|
||||
export function legacyCoreSpecifier(specifier) {
|
||||
if (specifier.startsWith("@/")) {
|
||||
const rest = clean(specifier.slice(2));
|
||||
if (rest === "types" || rest.startsWith("types/")) {
|
||||
return LEGACY_CORE_PREFIX + rest;
|
||||
}
|
||||
return `${LEGACY_CORE_PREFIX}ui/${rest}`;
|
||||
}
|
||||
const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier);
|
||||
if (relative) {
|
||||
return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value import in a source file that names a legacy core module.
|
||||
* Type-only imports are skipped: they are erased and never reach the map.
|
||||
*/
|
||||
export function findLegacyCoreImports(source) {
|
||||
const found = new Set();
|
||||
const patterns = [
|
||||
/^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm,
|
||||
/^\s*import\s+["']([^"']+)["']/gm,
|
||||
/import\(\s*["']([^"']+)["']\s*\)/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const normalized = legacyCoreSpecifier(match[1]);
|
||||
if (normalized) found.add(normalized);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -263,6 +263,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"guest": { "type": "boolean" },
|
||||
"panels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardCards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -296,7 +323,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["button"]
|
||||
"enum": ["button", "component"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +344,7 @@
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["button"]
|
||||
"enum": ["button", "component"]
|
||||
}
|
||||
},
|
||||
"descriptionKey": {
|
||||
|
||||
@@ -1,29 +1,478 @@
|
||||
/**
|
||||
* The frontend contract: what a plugin's frontend entry receives.
|
||||
* The frontend contract: what a plugin's frontend entry receives, and the
|
||||
* hooks its components use.
|
||||
*
|
||||
* Stubs only. A7 builds the real loader and fills these in, at which point a
|
||||
* plugin registers every surface it contributes through this object and the
|
||||
* shell stops carrying plugin ids of its own.
|
||||
* A plugin frontend is `src/frontend/index.tsx` exporting `activate(app)` and
|
||||
* optionally `deactivate()`. Everything it contributes goes through `app`, and
|
||||
* everything registered there is removed when the plugin is disabled, without
|
||||
* a page reload.
|
||||
*
|
||||
* This module has no runtime dependencies. The hooks delegate to a host that
|
||||
* core installs at startup (`__setPluginHost`), because React, i18next and the
|
||||
* shell's state all live in core and must be one instance across the shell
|
||||
* and every plugin bundle.
|
||||
*/
|
||||
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
export type Disposer = () => void;
|
||||
|
||||
/** A Lucide icon or anything with the same props. */
|
||||
export type IconComponent = ComponentType<{
|
||||
className?: string;
|
||||
size?: number | string;
|
||||
strokeWidth?: number | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A host as the shell holds it. Only the identifying fields are typed; the
|
||||
* rest of the record is passed through as the shell has it, so a plugin
|
||||
* that needs a feature column reads it by name.
|
||||
*/
|
||||
export interface PluginHostRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An open tab as the shell holds it. `data` is the plugin's own payload. */
|
||||
export interface PluginTabRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
host?: PluginHostRecord;
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenTabOptions {
|
||||
label?: string;
|
||||
/** Open a second tab even when one for this host and type exists. */
|
||||
forceNewTab?: boolean;
|
||||
/** Plugin payload, stored on the tab and restored with it. */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A saved arrangement of tabs, used by workspaces. Opaque to plugins. */
|
||||
export interface ShellLayout {
|
||||
version: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** What a plugin may ask of the shell. */
|
||||
export interface ShellApi {
|
||||
openTab: (
|
||||
host: PluginHostRecord | null,
|
||||
type: string,
|
||||
options?: OpenTabOptions,
|
||||
) => void;
|
||||
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
renameTab: (tabId: string, label: string) => void;
|
||||
openFileInEditor: (host: PluginHostRecord, path: string) => void;
|
||||
openFileManager: (host: PluginHostRecord, path?: string) => void;
|
||||
openTerminalTab: (host: PluginHostRecord, path?: string) => void;
|
||||
/** Opens a rail view in the left sidebar. */
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
closeRailView: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface TabsApi extends Pick<
|
||||
ShellApi,
|
||||
"openTab" | "openSingletonTab" | "closeTab"
|
||||
> {
|
||||
/** The current tabs and split layout, or null before the shell mounts. */
|
||||
getLayout: () => ShellLayout | null;
|
||||
/**
|
||||
* Replaces the open tabs with a saved layout. `name` labels a restored
|
||||
* split. Resolves with the tabs that could not be reopened, e.g. because
|
||||
* their host was deleted.
|
||||
*/
|
||||
applyLayout: (
|
||||
layout: ShellLayout,
|
||||
options?: { name?: string },
|
||||
) => Promise<{ skipped: string[] }>;
|
||||
/** Called whenever tabs open, close or move. */
|
||||
onChange: (listener: () => void) => Disposer;
|
||||
/** Called once the shell has restored its tabs after login. */
|
||||
onReady: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RailItemContribution {
|
||||
/** Must be declared in manifest contributes.tabs or contributes.panels. */
|
||||
id: string;
|
||||
icon: IconComponent;
|
||||
titleKey: string;
|
||||
/** "panel" opens the left sidebar, "tab" opens a tab. Default "panel". */
|
||||
kind?: "panel" | "tab";
|
||||
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
|
||||
hideable?: boolean;
|
||||
/** Can also open as a full-width tab. */
|
||||
promotable?: boolean;
|
||||
/** Can open in the right dock. */
|
||||
rightDockable?: boolean;
|
||||
/** Shown on the mobile bar's primary row. */
|
||||
mobilePrimary?: boolean;
|
||||
/** Desktop app only. */
|
||||
electronOnly?: boolean;
|
||||
separatorAfter?: boolean;
|
||||
/** Hidden without being unregistered, e.g. while a feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/** Place it after this rail id rather than at the end. */
|
||||
after?: string;
|
||||
/** Lower sorts first among plugin items. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
/** Whether the panel is the one currently shown. */
|
||||
active: boolean;
|
||||
shell: ShellApi;
|
||||
/** Tells the shell the panel is editing, which widens the sidebar. */
|
||||
setEditing: (editing: boolean) => void;
|
||||
/** Type of the focused tab, if any. */
|
||||
activeTabType?: string;
|
||||
/** Where the panel is rendered. */
|
||||
placement: "left" | "right" | "tab";
|
||||
}
|
||||
|
||||
export interface PanelOptions {
|
||||
/** Keep the panel mounted after the user switches away. */
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
tab: PluginTabRecord;
|
||||
host?: PluginHostRecord;
|
||||
/** The host in the shape the connection APIs take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
/** Forward to a component exposing `refresh()` or `disconnect()`. */
|
||||
handleRef: Ref<unknown>;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface StandaloneViewProps {
|
||||
hostId?: string;
|
||||
view: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface TabOptions {
|
||||
icon?: IconComponent;
|
||||
/** Label for singleton tabs and anywhere the type is named. */
|
||||
titleKey?: string;
|
||||
/** Needs a host; renders `noHostMessageKey` without one. */
|
||||
requiresHost?: boolean;
|
||||
noHostMessageKey?: string;
|
||||
/** Reopened after login. */
|
||||
persistent?: boolean;
|
||||
/** One tab of this type at a time, keyed by type. */
|
||||
singleton?: boolean;
|
||||
/** A live session: asks before closing and can be refreshed. */
|
||||
session?: boolean;
|
||||
/** Restorable without a host. */
|
||||
hostless?: boolean;
|
||||
/** Decides whether a saved tab for this host may be restored. */
|
||||
restore?: (host: PluginHostRecord) => boolean;
|
||||
/** Recent-activity types that open this tab. */
|
||||
activityTypes?: string[];
|
||||
/** Rendered for `?view=<type>` full-screen links. */
|
||||
standalone?: ComponentType<StandaloneViewProps>;
|
||||
/** Extra `?view=` names that also open `standalone`. */
|
||||
standaloneViews?: string[];
|
||||
/** Wrap in the readable-width frame used by panels opened as tabs. */
|
||||
panelFrame?: boolean;
|
||||
/** False keeps the tab out of saved layouts and workspaces. Default true. */
|
||||
inLayouts?: boolean;
|
||||
/** Warms the tab's code before it is opened. */
|
||||
preload?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostEditorSectionProps {
|
||||
// The editor form is a plain object; a section reads the fields it owns.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: any;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
/** Sets several fields at once, computed from the latest form. */
|
||||
updateForm: (
|
||||
patch: (form: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
host?: PluginHostRecord;
|
||||
credentials?: unknown[];
|
||||
snippets?: unknown[];
|
||||
/** Which connection protocols are switched on for this host. */
|
||||
protocols: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface HostEditorSectionContribution {
|
||||
id: string;
|
||||
/** "top" sits in the main tab strip, "ssh" under the SSH group. */
|
||||
group: "top" | "ssh";
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
/**
|
||||
* Position among the group's tabs, core ones included. Top: General 0,
|
||||
* SSH 10. SSH group: General 0, Terminal 10, Tunnels 20, Files 60.
|
||||
*/
|
||||
order?: number;
|
||||
/** Whether to offer the tab, from the host's enabled protocols. */
|
||||
visible?: (protocols: Record<string, boolean>) => boolean;
|
||||
component: ComponentType<HostEditorSectionProps>;
|
||||
}
|
||||
|
||||
export interface HostActionContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon: IconComponent;
|
||||
/**
|
||||
* "connect" is a way to open a session (terminal, RDP) and is offered as a
|
||||
* host's default action by priority. "open" opens a tool for the host.
|
||||
*/
|
||||
kind: "connect" | "open";
|
||||
/** Higher wins when choosing a host's default connect action. */
|
||||
priority?: number;
|
||||
/** Opened with shell.openTab when `run` is absent. */
|
||||
tabType?: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
run?: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
/** Shows a quick button on the host row. Default true. */
|
||||
tray?: boolean;
|
||||
/** `?view=` name for "Copy link". */
|
||||
copyUrlView?: string;
|
||||
/** Where a host overview (the dashboard's host status list) sends a click. */
|
||||
overview?: boolean;
|
||||
/**
|
||||
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
|
||||
* actions default to 100 and sit after a separator.
|
||||
*/
|
||||
order?: number;
|
||||
/** A label worked out per host, e.g. a single endpoint's name. */
|
||||
label?: (host: PluginHostRecord) => string | undefined;
|
||||
/** Several targets: two or more become a picker, one runs directly. */
|
||||
items?: (host: PluginHostRecord) => {
|
||||
id: string;
|
||||
label: string;
|
||||
run: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<{ host: PluginHostRecord }>;
|
||||
}
|
||||
|
||||
export interface HostContextMenuItemContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
run: (host: PluginHostRecord, shell: ShellApi) => void;
|
||||
}
|
||||
|
||||
export interface PaletteEntryContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
keywords?: string[];
|
||||
/** "global" entries stand alone, "host" entries appear under a host. */
|
||||
scope: "global" | "host";
|
||||
when?: (host?: PluginHostRecord) => boolean;
|
||||
run: (shell: ShellApi, host?: PluginHostRecord) => void;
|
||||
}
|
||||
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface DashboardCardContribution {
|
||||
/** Must be declared in manifest contributes.dashboardCards. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
defaultHeight?: number;
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
export interface HomepageWidgetContribution<C = Record<string, unknown>> {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "links" | "info" | "system" | "monitoring";
|
||||
icon: ReactNode;
|
||||
defaultConfig: C;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize: { w: number; h: number };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editFormComponent?: ComponentType<any>;
|
||||
}
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
pluginId: string;
|
||||
values: Record<string, unknown>;
|
||||
setValue: (key: string, value: unknown) => void;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
id: string;
|
||||
/** Contribution kinds the slot renders: "button", "component". */
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
export interface SlotContribution {
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
kind?: "button" | "component" | string;
|
||||
/** For kind "component". Receives the slot owner's props. */
|
||||
component?: ComponentType<Record<string, unknown>>;
|
||||
/** Extra condition on top of the action's permission. */
|
||||
when?: (context: Record<string, unknown>) => boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorProps {
|
||||
form: Record<string, unknown>;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorContribution {
|
||||
/** The value stored in the host's authType. */
|
||||
authType: string;
|
||||
titleKey: string;
|
||||
hintKey?: string;
|
||||
component?: ComponentType<SshAuthEditorProps>;
|
||||
}
|
||||
|
||||
/** A7 declares these; A8 wires them into the login screen. */
|
||||
export interface LoginMethodContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface SecondFactorContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** The subset of axios a plugin uses, rooted at /plugin-api/<id>/. */
|
||||
export interface PluginApiClient {
|
||||
get<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
|
||||
delete<T = unknown>(url: string, config?: unknown): Promise<{ data: T }>;
|
||||
post<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
put<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: unknown,
|
||||
): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
export interface PluginWsTarget {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface TermixAppInfo {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration surface for a plugin frontend.
|
||||
*
|
||||
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
|
||||
* badges, context menu items, palette entries, dashboard cards, homepage
|
||||
* widgets, settings components, action slots, login/2FA UI and SSH auth
|
||||
* editors. Everything registered here is removed when the plugin is disabled.
|
||||
* Registration surface for a plugin frontend. Every `register*` returns a
|
||||
* disposer, and anything not disposed by hand is disposed when the plugin is
|
||||
* disabled.
|
||||
*/
|
||||
export interface TermixApp extends TermixAppInfo {
|
||||
/**
|
||||
* True on anonymous guest pages (a shared-session link). Only plugins with
|
||||
* contributes.guest run there, and they should register just what a guest
|
||||
* sees: there is no user, so API calls needing a login will fail.
|
||||
*/
|
||||
readonly guest: boolean;
|
||||
registerRailItem: (item: RailItemContribution) => Disposer;
|
||||
registerPanel: (
|
||||
id: string,
|
||||
component: ComponentType<PanelProps>,
|
||||
options?: PanelOptions,
|
||||
) => Disposer;
|
||||
registerTab: (
|
||||
type: string,
|
||||
component: ComponentType<TabProps>,
|
||||
options?: TabOptions,
|
||||
) => Disposer;
|
||||
registerHostEditorSection: (
|
||||
section: HostEditorSectionContribution,
|
||||
) => Disposer;
|
||||
registerHostAction: (action: HostActionContribution) => Disposer;
|
||||
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
||||
registerHostContextMenuItem: (
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||
registerDashboardCard: (card: DashboardCardContribution) => Disposer;
|
||||
registerHomepageWidget: (widget: HomepageWidgetContribution) => Disposer;
|
||||
registerSettingsComponent: (
|
||||
componentId: string,
|
||||
component: ComponentType<SettingsComponentProps>,
|
||||
) => Disposer;
|
||||
registerAction: (
|
||||
id: string,
|
||||
handler: ActionHandler,
|
||||
options?: { permission?: string },
|
||||
) => Disposer;
|
||||
declareActionSlot: (slot: ActionSlotDefinition) => Disposer;
|
||||
registerSlotContribution: (
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
) => Disposer;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
|
||||
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
|
||||
api: PluginApiClient;
|
||||
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
|
||||
wsUrl: (
|
||||
path: string,
|
||||
options?: { origin?: unknown },
|
||||
) => Promise<PluginWsTarget | null>;
|
||||
tabs: TabsApi;
|
||||
/** Registered cleanup, run when the plugin is disabled. */
|
||||
onDispose: (dispose: () => void) => void;
|
||||
onDispose: (dispose: Disposer) => void;
|
||||
}
|
||||
|
||||
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
|
||||
@@ -37,4 +486,158 @@ export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranslateFn = (
|
||||
key: string,
|
||||
options?: Record<string, unknown> | string,
|
||||
) => string;
|
||||
|
||||
export interface CurrentUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
}
|
||||
|
||||
export type SettingsScope = "admin" | "user" | "host";
|
||||
|
||||
export interface SettingsState {
|
||||
values: Record<string, unknown>;
|
||||
loaded: boolean;
|
||||
save: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What core implements behind the hooks. Internal: a plugin never calls this.
|
||||
*/
|
||||
export interface PluginHostBridge {
|
||||
usePluginId: () => string;
|
||||
useTranslation: (pluginId: string) => {
|
||||
t: TranslateFn;
|
||||
language: string;
|
||||
};
|
||||
usePermission: (pluginId: string, permission: string) => boolean;
|
||||
useSettings: (
|
||||
pluginId: string,
|
||||
scope: SettingsScope,
|
||||
hostId?: number | string,
|
||||
) => SettingsState;
|
||||
useHost: (hostId: string | number | undefined) => PluginHostRecord | null;
|
||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||
useCurrentUser: () => CurrentUser | null;
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
useTabs: () => TabsApi;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
let host: PluginHostBridge | null = null;
|
||||
|
||||
/** Called once by core. Not part of the plugin API. */
|
||||
export function __setPluginHost(bridge: PluginHostBridge | null): void {
|
||||
host = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set by the shared test setup, so a plugin component rendered on its own in a
|
||||
* unit test still has hooks. A global rather than an import, because the test
|
||||
* setup and the component can load different copies of this module.
|
||||
*/
|
||||
const TEST_HOST_KEY = "__termixTestPluginHost";
|
||||
|
||||
function requireHost(): PluginHostBridge {
|
||||
const fallback = (globalThis as Record<string, unknown>)[TEST_HOST_KEY] as
|
||||
PluginHostBridge | undefined;
|
||||
if (!host && fallback) return fallback;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/** The id of the plugin that registered the component being rendered. */
|
||||
export function usePluginId(): string {
|
||||
return requireHost().usePluginId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation bound to this plugin's namespace. Keys the plugin does not
|
||||
* define fall back to core's shared strings (common.*, hosts.*).
|
||||
*/
|
||||
export function useTranslation(): { t: TranslateFn; language: string } {
|
||||
const bridge = requireHost();
|
||||
return bridge.useTranslation(bridge.usePluginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user holds a permission. A short name resolves to this
|
||||
* plugin's own namespace, a dotted id is taken as given. UI only: the backend
|
||||
* route checks again.
|
||||
*/
|
||||
export function usePermission(permission: string): boolean {
|
||||
const bridge = requireHost();
|
||||
return bridge.usePermission(bridge.usePluginId(), permission);
|
||||
}
|
||||
|
||||
export function useSettings(
|
||||
scope: SettingsScope,
|
||||
hostId?: number | string,
|
||||
): SettingsState {
|
||||
const bridge = requireHost();
|
||||
return bridge.useSettings(bridge.usePluginId(), scope, hostId);
|
||||
}
|
||||
|
||||
export function useHost(
|
||||
hostId: string | number | undefined,
|
||||
): PluginHostRecord | null {
|
||||
return requireHost().useHost(hostId);
|
||||
}
|
||||
|
||||
export function useHosts(): { hosts: PluginHostRecord[]; loaded: boolean } {
|
||||
return requireHost().useHosts();
|
||||
}
|
||||
|
||||
export function useCurrentUser(): CurrentUser | null {
|
||||
return requireHost().useCurrentUser();
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: "light" | "dark" } {
|
||||
return requireHost().useTheme();
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
export function usePluginApi(): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApi(bridge.usePluginId());
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action another plugin registered, from anywhere in a plugin's code.
|
||||
* Resolves undefined when nobody registered it, so an optional dependency
|
||||
* that is switched off reads as "nothing there".
|
||||
*/
|
||||
export function invokeAction(id: string, ...args: unknown[]): Promise<unknown> {
|
||||
return requireHost().invokeAction(id, ...args);
|
||||
}
|
||||
|
||||
export type { PluginManifest } from "./manifest.js";
|
||||
export type { ReactNode };
|
||||
@@ -31,7 +31,7 @@ export const PLUGIN_CATEGORIES = [
|
||||
|
||||
export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const;
|
||||
|
||||
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
|
||||
export const ACTION_CONTRIBUTION_KINDS = ["button", "component"] as const;
|
||||
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
|
||||
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
|
||||
@@ -97,6 +97,17 @@ export interface PluginTabContribution {
|
||||
openFrom: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sidebar panel or dashboard card the plugin's frontend registers. Declared
|
||||
* here so core knows who owns a saved view even while the plugin is off and
|
||||
* its code never loaded.
|
||||
*/
|
||||
export interface PluginViewContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One role permission a plugin contributes.
|
||||
*
|
||||
@@ -224,6 +235,13 @@ export interface PluginSettingsContribution {
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
dashboardCards?: PluginViewContribution[];
|
||||
/**
|
||||
* The frontend also runs on anonymous guest pages (shared-session and
|
||||
* collab links), where it is activated with app.guest set.
|
||||
*/
|
||||
guest?: boolean;
|
||||
actions?: PluginActionContribution[];
|
||||
actionSlots?: PluginActionSlot[];
|
||||
permissions?: PluginPermissionContribution[];
|
||||
@@ -302,6 +320,9 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
|
||||
const ALLOWED_CONTRIBUTES = new Set([
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"guest",
|
||||
"actions",
|
||||
"actionSlots",
|
||||
"permissions",
|
||||
@@ -650,6 +671,14 @@ function validateContributes(
|
||||
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
|
||||
|
||||
validateTabs(contributes.tabs, errors);
|
||||
validateViews(contributes.panels, "panels", errors);
|
||||
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
||||
if (
|
||||
contributes.guest !== undefined &&
|
||||
typeof contributes.guest !== "boolean"
|
||||
) {
|
||||
errors.push('Field "contributes.guest" must be a boolean');
|
||||
}
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
@@ -884,6 +913,30 @@ function validateTabs(tabs: unknown, errors: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function validateViews(views: unknown, field: string, errors: string[]): void {
|
||||
if (views === undefined) return;
|
||||
if (!Array.isArray(views)) {
|
||||
errors.push(`Field "contributes.${field}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
views.forEach((raw, index) => {
|
||||
const where = `contributes.${field}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, ["id", "titleKey", "icon"], where, errors);
|
||||
if (requireString(raw.id, `${where}.id`, errors)) {
|
||||
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
requireString(raw.titleKey, `${where}.titleKey`, errors);
|
||||
if (raw.icon !== undefined)
|
||||
requireString(raw.icon, `${where}.icon`, errors);
|
||||
});
|
||||
}
|
||||
|
||||
function validatePermissions(
|
||||
permissions: unknown,
|
||||
pluginId: string | undefined,
|
||||
|
||||
@@ -443,43 +443,88 @@ export function createMockCtx(
|
||||
}
|
||||
|
||||
/**
|
||||
* A TermixApp double for frontend tests.
|
||||
* Rendering a plugin frontend in a test.
|
||||
*
|
||||
* A7 builds the real registration surface; until then this records what a
|
||||
* plugin's frontend entry registered, which is all a test can assert about a
|
||||
* loader that does not exist yet. Deliberately free of any render library so
|
||||
* a plugin only needs testing-library if its own tests render something.
|
||||
* renderWithApp activates the plugin against the real registration surface,
|
||||
* so a test sees exactly what the shell would: which rail items, tabs, host
|
||||
* actions and cards it registered, and each of those rendered with the SDK
|
||||
* hooks working. It is implemented by the Termix host, which is where the
|
||||
* registries live; the vitest preset points "@termix/plugin-host/testing" at
|
||||
* core's implementation.
|
||||
*/
|
||||
export interface FakeTermixApp {
|
||||
export interface RenderWithAppOptions {
|
||||
pluginId?: string;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Role permissions the current user holds. */
|
||||
permissions?: string[];
|
||||
isAdmin?: boolean;
|
||||
/** Hosts useHosts() returns. */
|
||||
hosts?: Array<Record<string, unknown>>;
|
||||
/** The plugin's locales/en.json, loaded into its namespace. */
|
||||
locales?: Record<string, unknown>;
|
||||
/** Render as an anonymous guest page. */
|
||||
guest?: boolean;
|
||||
}
|
||||
|
||||
/** A call a plugin made on the shell, recorded instead of performed. */
|
||||
export interface ShellCall {
|
||||
method: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
export interface RenderedPluginApp {
|
||||
app: TermixApp;
|
||||
/** Cleanups the plugin handed to app.onDispose. */
|
||||
disposals: Array<() => void>;
|
||||
}
|
||||
|
||||
export function renderWithApp(
|
||||
options: { pluginId?: string; manifest?: Partial<PluginManifest> } = {},
|
||||
): FakeTermixApp {
|
||||
const pluginId = options.pluginId ?? "test-plugin";
|
||||
const disposals: Array<() => void> = [];
|
||||
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
...options.manifest,
|
||||
} as PluginManifest;
|
||||
|
||||
const app: TermixApp = {
|
||||
pluginId,
|
||||
manifest,
|
||||
onDispose: (dispose) => disposals.push(dispose),
|
||||
/** Everything this plugin registered, by kind. */
|
||||
registered: {
|
||||
railItems: () => Array<{ id: string; hidden?: boolean }>;
|
||||
tabs: () => string[];
|
||||
panels: () => string[];
|
||||
hostActions: () => Array<{ id: string; tabType?: string }>;
|
||||
hostEditorSections: () => string[];
|
||||
dashboardCards: () => string[];
|
||||
settingsComponents: () => string[];
|
||||
slot: (slotId: string) => string[];
|
||||
actions: () => string[];
|
||||
};
|
||||
/** Shell calls made by the plugin's code. */
|
||||
shellCalls: ShellCall[];
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
renderHostEditorSection: (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => HTMLElement;
|
||||
renderSettingsComponent: (
|
||||
componentId: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => HTMLElement;
|
||||
/** Renders a slot the way its owner would. */
|
||||
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
/** Runs deactivate and every disposer, as disabling the plugin does. */
|
||||
deactivate: () => Promise<void>;
|
||||
}
|
||||
|
||||
return { app, disposals };
|
||||
export type FrontendPluginModule = {
|
||||
activate: (app: TermixApp) => void | Promise<void>;
|
||||
deactivate?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
interface PluginTestHost {
|
||||
renderPlugin: (
|
||||
plugin: FrontendPluginModule,
|
||||
options: RenderWithAppOptions,
|
||||
) => Promise<RenderedPluginApp>;
|
||||
}
|
||||
|
||||
const TEST_HOST = "@termix/plugin-host/testing";
|
||||
|
||||
export async function renderWithApp(
|
||||
plugin: FrontendPluginModule,
|
||||
options: RenderWithAppOptions = {},
|
||||
): Promise<RenderedPluginApp> {
|
||||
// A variable specifier keeps bundlers from resolving it at build time; the
|
||||
// test runner resolves it through its alias.
|
||||
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
|
||||
return host.renderPlugin(plugin, options);
|
||||
}
|
||||
@@ -63,6 +63,46 @@ if (typeof globalThis.localStorage === "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain plugin host for components a test renders on its own: keys come
|
||||
* back as text, every permission is held, and nothing reaches a server.
|
||||
* renderWithApp installs Termix's real host instead, which takes precedence.
|
||||
*/
|
||||
const noop = () => {};
|
||||
const offline = () =>
|
||||
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
||||
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
||||
usePluginId: () => "test-plugin",
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
language: "en",
|
||||
}),
|
||||
usePermission: () => true,
|
||||
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
|
||||
useHost: () => null,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
getApi: () => ({
|
||||
get: offline,
|
||||
delete: offline,
|
||||
post: offline,
|
||||
put: offline,
|
||||
patch: offline,
|
||||
}),
|
||||
useTabs: () => ({
|
||||
openTab: noop,
|
||||
openSingletonTab: noop,
|
||||
closeTab: noop,
|
||||
getLayout: () => null,
|
||||
applyLayout: async () => ({ skipped: [] }),
|
||||
onChange: () => noop,
|
||||
onReady: () => noop,
|
||||
}),
|
||||
invokeAction: async () => undefined,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -48,9 +48,33 @@ export function pluginVitestConfig(
|
||||
);
|
||||
|
||||
const repoRoot = findRepoRoot(pluginDir);
|
||||
// Inside a Termix checkout the SDK's browser entries resolve to source and
|
||||
// the test host is core's, so renderWithApp exercises the real registries
|
||||
// and the plugin shares one SDK instance with them.
|
||||
const alias: Record<string, string> = {
|
||||
...(repoRoot
|
||||
? {
|
||||
"@termix/plugin-sdk/frontend": path.join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"src",
|
||||
"frontend.ts",
|
||||
),
|
||||
"@termix/plugin-sdk/ui": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"sdk-ui.ts",
|
||||
),
|
||||
"@termix/plugin-host/testing": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"testing-host.tsx",
|
||||
),
|
||||
"@/types": path.join(repoRoot, "src", "types"),
|
||||
"@": path.join(repoRoot, "src", "ui"),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user