* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
45 KiB
Termix plugin guide
SDK 1.0.6 | Plugin API 1.2.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
@termix-ssh/plugin-sdk, so the copy in your node_modules always matches the SDK you have installed.
Run npm update @termix-ssh/plugin-sdk to get the newest one.
If this guide and the SDK's TypeScript types disagree, the types are right. They live next to this file
in dist/*.d.ts. The long form of everything here is at https://docs.termix.site/develop, and the raw
Markdown of each page is at
https://raw.githubusercontent.com/Termix-SSH/Docs/main/develop/<page>.md (for example
develop/backend.md), which is easier for tools to read than the site.
What a plugin is
Termix is self-hosted, plugin-based server management. Core keeps hosts, credentials, users, sharing and the shell. Everything else, even the SSH terminal, is a plugin built with this SDK.
A plugin is:
manifest.json: what it is, what it adds and what it may do. Termix reads it without running any code.- A backend,
src/backend/index.ts, run inside the Termix server process. It exportsactivate(ctx). - A frontend,
src/frontend/index.tsx, run in the browser in the same page as Termix. It exportsactivate(app).
There is no sandbox. Capabilities make a plugin honest and easy to audit, but code runs with the server's rights. Write it as if an admin will read every line, because reviewers do.
Hard rules
Break one of these and the plugin fails to load, fails validation or gets rejected in review.
- Import only
@termix-ssh/plugin-sdk/*, your own files and npm packages. Never import Termix's own code. - Create everything inside
activate. No servers, timers, sockets or connections at the top of a module. Disable then enable runsactivateagain on the same module, and it must work without a restart. - Clean up what you make yourself with
ctx.disposables.add(fn)(backend) orapp.onDispose(fn)(frontend). Things made throughctxandappare cleaned up for you. - Never call
process.exitor touch signals. - Never open your own SSH connection. Use
ctx.sshso jump hosts, proxies, host keys, every auth type and sharing work the way the user set them up. - Outbound HTTP goes through
ctx.fetch(needsnetwork:outbound), not a rawfetchor axios. - Ask for the fewest capabilities you can. A call that needs one you didn't list throws
PluginCapabilityError. - Never take a user id from a request body or a service caller. Use
ctx.currentActor(). - Check permissions on the backend. Frontend checks only hide UI.
- Call
await ctx.db.persist()after every database write. On SQLite a write without it can be lost on restart. - Never use
.returning()in a query. MySQL doesn't have it. Read a new row back by a key you chose. - Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a new one.
- Never index a
text()orjson()column. Usevarchar(n)for anything you index. - Never hard code UI text. Every string goes in
locales/en.jsonand is read witht(). - The plugin
idnever changes once released. It names your tables, routes and permissions. - The version in
manifest.json,package.jsonand the newestCHANGELOG.mdsection must match. - Put an
@openapiJSDoc block above every route. - No obfuscated or minified source, and no code downloaded and run at runtime.
- No tracking, telemetry or network calls the user didn't ask for. If you need one, make it off by default and explain it in your docs.
Project layout
manifest.json what the plugin is, adds and may do
package.json npm scripts and dependencies
CHANGELOG.md release notes, one section per version
README.md the repo front page
AGENTS.md notes for AI tools working on this plugin
docs/index.md the plugin's docs page (more pages next to it)
src/backend/index.ts activate(ctx), runs in the Termix server
src/backend/tables.ts table definitions, exports `tables`
src/backend/routes.ts HTTP routes
src/frontend/index.tsx activate(app), runs in the browser
locales/en.json every string the plugin shows
locales/translated/ other languages, like de_DE.json (optional)
migrations/ sqlite/, postgres/, mysql/ and snapshot.json, made by npm run migrations
tests/backend/ backend tests (Node)
tests/frontend/ frontend tests (jsdom)
vitest.config.ts uses the SDK preset
.github/workflows/ ci.yml and release.yml, calling shared workflows in Termix-Registry
dist/ build output, never commit it
Commands
npm install
npm run build # termix-plugin build: dist/backend.js, dist/frontend.js, CSS, openapi.json
npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run format # Prettier
npm run format:check
Done checklist
Before you call a change finished:
npm testpassesnpm run typecheckpassesnpm run validatepasses with no warnings you can fixnpm run buildpassesnpm run format:checkpassesnpx termix-plugin migrations --checkpasses (you rannpm run migrationsafter changing tables)- New strings are in
locales/en.json - New routes have an
@openapiblock and a test, including without the permission - New env vars are listed in
envin the manifest docs/and the "About this plugin" section ofAGENTS.mdstill match what the plugin does- User facing changes are in
CHANGELOG.mdunder the version they will ship in
Manifest
{
"$schema": "./node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json",
"id": "hello",
"name": "Hello",
"version": "1.0.0",
"description": "Says hello and keeps a few notes.",
"author": { "name": "You", "url": "https://example.com" },
"license": "Apache-2.0",
"repository": "https://github.com/you/termix-plugin-hello",
"docs": "https://github.com/you/termix-plugin-hello/tree/main/docs",
"category": "Productivity",
"icon": "Hand",
"features": ["A tab from the sidebar", "Notes per user"],
"engine": { "termix": ">=26.10.0", "api": "^1.2" },
"capabilities": ["db:own", "network:serve", "ui:surface"],
"env": [],
"contributes": {
"tabs": [
{
"id": "hello",
"titleKey": "tab.title",
"icon": "Hand",
"openFrom": ["rail"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
Unknown fields are refused at every level, so a typo fails validation. The schema at
node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json has every field.
| Field | Notes |
|---|---|
id |
Lowercase, starts with a letter, 2 to 40 characters, letters, numbers and dashes. Can't be an official plugin's id. |
version |
Semver. Betas are X.Y.Z-beta.N. |
category |
One of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. |
icon |
A Lucide icon name, like Hand. |
features |
Up to 20 short lines shown on the plugin's page. |
docs |
An https link to your docs. |
engine.termix |
Termix versions you work with, like >=26.10.0. |
engine.api |
Plugin API range, like ^1.2. A major bump of the API is breaking. |
capabilities |
What your code may do. See the reference at the end. |
dependencies, optionalDependencies |
Other plugins, { "snippets": "^1.0.0" }. You must keep working without optional ones. |
provides, requires |
Services you offer and use. |
env |
Every environment variable your backend reads. validate warns about unlisted process.env.X. Prefer settings. |
desktop |
mirror (default), local or server: how a linked desktop app treats the plugin. |
nativeDependencies |
npm packages with .node bindings. Avoid them: community plugins can't count on them. |
contributes holds tabs, panels, dashboardCards, actions, actionSlots, permissions,
settings, protocols, auth, syncEntities, keybindingActions, http, guest, guestViews,
uiPresets and uses. Frontend register* calls need their ids declared here first.
Capabilities and permissions are different things. A capability (hosts:read, with a colon) is what your
code may reach. A permission (hello.use, with a dot) is what a user may do.
Backend
import type { Router } from "express";
import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
import { notes } from "./tables.js";
import { registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const notesTable = await ctx.db.define(notes);
registerRoutes(ctx.http.router<Router>(), ctx, notesTable);
}
export async function deactivate() {
// Everything registered through ctx is disposed by core.
}
Bundled as ESM for Node 22+ into dist/backend.js. Backend imports of your own files use .js.
What ctx has
| Member | Capability | What it does |
|---|---|---|
pluginId, manifest |
none | Your id and manifest |
log |
none | Log lines tagged with your plugin |
db |
db:own |
Your own tables |
kv |
kv:own |
Small key value state |
files.dataDir() |
files:own |
A folder of your own |
settings |
none | Your settings |
secrets |
secrets:own |
Encrypted values per user, seal/unseal for your own tables |
http, ws |
network:serve |
Routes and sockets |
rbac |
none | Check the actor's permissions |
hosts |
hosts:read, hosts:write |
List, read, create, update and share hosts as the actor |
ssh |
ssh:connect, credentials:use |
Connect to hosts through Termix's SSH pipeline |
credentials |
varies | The actor's saved keys, and protocol logins |
services |
per service | Call other plugins and offer your own |
events |
events:core for core topics |
Emit and listen for events |
notify |
notify:send |
Send alerts to the Alerts inbox |
fetch |
network:outbound |
Outbound HTTP with SSRF protection |
process |
process:spawn |
Run programs on the server |
schedule |
none | Timers that stop on disable and never overlap |
audit.record |
none | Write your own audit line |
sync |
none | Sync your tables to a linked desktop |
auth |
auth:provide |
Login methods, second factors, SSH auth types |
system |
system:tls |
The server's HTTPS certificate |
plugins.list() |
plugins:read |
Which plugins are installed |
asUser(userId, fn) |
always audited | Run background work as a user |
currentActor() |
none | The user the current call runs for |
disposables.add(fn) |
none | Extra cleanup on disable |
The actor
In a route the actor is the signed-in user, set by Termix. ctx.currentActor() returns it. Background
work with no request (a timer, an event) uses ctx.asUser(userId, fn), which is audited and still applies
that user's permissions.
Routes
Routes live at /plugin-api/<id>/, sockets at /plugin-ws/<id>/, on the main Termix port. You never need
your own port or an nginx change. Before your handler runs, Termix checks sign in, sets the actor, answers
503 if the plugin is off, parses JSON up to 2 MB and catches errors.
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/hello/notes:
* post:
* summary: Add a note for the signed-in user
* tags:
* - Hello
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* text:
* type: string
* responses:
* 201:
* description: Note added
* 400:
* description: Missing or empty text
*/
router.post("/notes", async (req, res) => {
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
if (!text) return res.status(400).json({ error: "text is required" });
const db = await ctx.db.client<BetterSQLite3Database>();
await db
.insert(notesTable)
.values({ userId: ctx.currentActor() as string, text });
await ctx.db.persist();
res.status(201).json({ success: true });
});
- The
@openapipath is the full path, starting with/plugin-api/<id>/.buildturns these intodist/openapi.jsonand the docs site renders them. ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false }). Public routes have no actor: check their token yourself and rate limit them.ctx.http.baseUrl(req)is the public address, for callback URLs.ctx.ws.route("/live", (socket, conn) => ...). Long lived sockets should checkconn.isDataUnlocked()per message.ctx.rbac.require("manage")on a single route,await ctx.rbac.has("manage")in code. A full id likehosts.editchecks a core permission.- Host access:
ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage").
Errors
Errors from routes, sockets, listeners and timers count against an error budget. Five in a minute and
Termix turns the plugin off (state failed). A 4xx you send on purpose doesn't count. Validate input and
send 400s instead of throwing.
SSH
const { client, dispose } = await ctx.ssh.connect(hostId, {
purpose: "hello",
profile: "background",
});
try {
// use the ssh2 client
} finally {
dispose();
}
ctx.ssh.withConnection(hostId, { pool, purpose }, fn) borrows a pooled connection for frequent short
commands. @termix-ssh/plugin-sdk/host-commands has helpers to detect the OS and run package manager and
sudo commands. Hosts from ctx.hosts never carry secrets. Pass the host or its id to ctx.ssh.
Events, timers, alerts, services
ctx.events.on("host.deleted", ({ hostId }) => cleanUp(hostId)); // core topics need events:core
ctx.events.emit("plugin.hello.greeted", { name }); // your own: plugin.<id>.<name>
ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Alerts go through
ctx.notify.send(notify:send). Never build your own notification system. - Offer a service: declare it in
provideswith a version and a permission, thenctx.services.provide("hello.greetings", impl). The name starts with your id. Methods read the caller fromctx.currentActor(). - Use a service: declare it in
requires, thenctx.services.get<T>("snippets.access"). It returns nothing while no provider runs, so always handle that.
Shared packages
These come from Termix and are never bundled: @termix-ssh/plugin-sdk, express, ssh2, ws, multer,
cookie-parser, axios, jszip, guacamole-lite, @anthropic-ai/sdk, drizzle-orm, sharp and every
Node builtin. List the ones you use in peerDependencies. Any other npm package is bundled.
Database
Needs db:own. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL,
query with Drizzle.
// src/backend/tables.ts
import {
defineTable,
id,
refUser,
text,
timestamp,
} from "@termix-ssh/plugin-sdk/db";
export const notes = defineTable(
"notes",
{
id: id(),
userId: refUser(),
text: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
{ indexes: [{ name: "idx_p_hello_notes_user_id", columns: ["userId"] }] },
);
export const tables = [notes]; // termix-plugin migrations reads this
The table is stored as p_<id with dashes as underscores>_notes. Index names should use the same prefix.
| Builder | Type |
|---|---|
id() |
Auto increment key |
text() |
Any length string. Not indexable |
varchar(n) |
String up to n (255 default). Indexable |
integer(), bigint(), real(), boolean() |
Numbers and booleans |
timestamp() |
A time, stored as text |
json() |
JSON stored as text. Not indexable |
encryptedText() |
Text encrypted with the owning user's key |
refUser() |
A user id. Rows are deleted with the user and included in their data export |
refHost() |
A host id. Rows are deleted with the host |
Modifiers: .notNull(), .unique(), .primaryKey(), .default(value), .defaultNow().
After any change to tables.ts, run npm run migrations and read the three new files. Renames and type
changes are refused rather than guessed: write those by hand, for all three databases. A migration may only
touch your own tables. createTestDb applies them in tests, so a broken one fails npm test.
Query through const db = await ctx.db.client(), then await ctx.db.persist() after writes
(persist({ lazy: true }) for frequent low value writes). ctx.db.dialect says which database runs.
Other storage: ctx.kv for a few values, ctx.settings for settings, ctx.secrets for secrets,
ctx.files.dataDir() for files. To sync a table to a linked desktop, use ctx.sync.registerEntity and
contributes.syncEntities. The entity name is part of the protocol and never changes.
Frontend
import {
useTranslation,
type TabProps,
type TermixApp,
} from "@termix-ssh/plugin-sdk/frontend";
import { EmptyState, PanelShell } from "@termix-ssh/plugin-sdk/ui";
import { Hand } from "lucide-react";
function HelloTab(_props: TabProps) {
const { t } = useTranslation();
return (
<PanelShell icon={<Hand className="size-4" />} title={t("tab.title")}>
<EmptyState title={t("tab.empty")} />
</PanelShell>
);
}
export function activate(app: TermixApp) {
app.registerRailItem({
id: "hello",
icon: Hand,
titleKey: "tab.title",
kind: "tab",
permission: "use",
});
app.registerTab("hello", HelloTab, {
icon: Hand,
titleKey: "tab.title",
singleton: true,
hostless: true,
});
}
Every register* returns an undo function, and everything is undone on disable. Each component runs in
your plugin's error boundary and Suspense.
| Member | What it does |
|---|---|
registerRailItem |
A sidebar rail button |
registerTab, registerPanel |
A tab type or sidebar panel (id in contributes.tabs / panels) |
registerDashboardCard |
A dashboard card (contributes.dashboardCards) |
registerHostAction |
A host button, palette entry or default connect |
registerHostEditorSection |
Your own host editor section (host settings get one for free) |
registerHostProtocol |
A connection protocol next to SSH |
registerHostBadge, registerHostContextMenuItem |
Badges and menu items on host rows |
registerPaletteEntry, registerPaletteGroup |
Command palette entries |
registerKeybindingAction, registerKeybindingDefault |
Bindable actions |
registerSettingsComponent |
A component for a custom settings field |
registerAction, invokeAction |
Actions across plugins |
registerSlotContribution, declareActionSlot |
Fill or own a UI slot |
registerComponent, registerExtension |
Offer a component by id, add to an extension point |
registerOnboardingStep |
A first run setup step |
registerLoginMethod, registerSecondFactorUI, registerSshAuthEditor |
Sign in UI |
api |
Axios client for your /plugin-api/<id>/ routes, auth included |
fetch(path), wsUrl(path) |
Raw fetch for streams, socket URL and auth |
docs, tabs, confirm, listHosts, getHost, t, hasPermission, desktop |
Helpers |
onSettingsChanged, onDispose |
Hooks |
Hooks from @termix-ssh/plugin-sdk/frontend: useTranslation, usePermission, useSettings, useHost,
useHosts, usePluginApi, useCurrentUser, useTheme, useToast, useDocsUrl, useTabs,
useSlotContributions, useSshAuthTypes, useExtensions.
Shared from Termix and never bundled: react, react-dom, i18next, react-i18next, sonner and the
SDK's frontend and ui. Anything else, like lucide-react, is bundled. A host your code gets only carries
your own host settings, under host.pluginSettings[<id>].
Slots you can fill include terminal.toolbar, terminal.toolbarStatus, terminal.sidePanel,
terminal.overlay, dashboard.counters, dashboard.hostMetrics, admin.userTabs, hosts.importMenu,
hosts.panel, credentials.badges, shell.overlay and tab.menu. See
https://docs.termix.site/develop/more-extension-points.
Looking native
Build UI from @termix-ssh/plugin-sdk/ui so it matches Termix and follows the theme. The full list of
exports with props is ui.api.txt in the Termix repo
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
- Flat: borders, not shadows. Use theme tokens like
border-border,bg-background,text-muted-foreground,text-destructive. One accent,text-accent-brand. Don't add colors. - Small type: most text 11 to 13px.
- No new modal dialogs. Ask with
useConfirm(), open views in place withInlineView, and usePanePromptfor small prompts. - Every tab or panel sits in
PanelShell. Lists usePanelListandListRow, add withAddButton, forms end withFormFooter, empty lists showEmptyState. - Forms:
TextField,NumberField,SelectField,SwitchRow,SecretField,SettingRowand friends. - Host sessions use
ConnectionGatefor the whole connect flow (host keys, TOTP, passphrases, errors). - Mobile: check
useIsMobile()and give small screens a layout that works. The desktop app always uses the desktop layout. - Tailwind classes work.
buildcompiles the ones you use against Termix's theme. - Link to your docs with
DocsLinkorPanelShell'sdocsprop, never a hard coded URL.
Settings
Declare them in contributes.settings and Termix draws the form, stores values, encrypts secrets and
checks saves. You don't build settings UI.
- Scopes:
admin(for everyone),user(each person),host(a section in the host editor, with anenableKeyswitch first). - Types:
boolean,string,number,select,multiselect,secret,textarea,json,custom. - Options:
labelKey,descriptionKey,placeholderKey,default,options,min,max,requires,group,permission,hidden,component. - Read:
ctx.settings.get(key),getUser(userId, key),getHost(hostId, key). Write:set,setHost. React:ctx.settings.onChange. Check:ctx.settings.onValidate("admin", values => ({ key: "message" })). - Frontend:
useSettings("user")gives values andsave.
Prefer settings to environment variables. Use an env var only for something needed before Termix starts.
Permissions
Declare short names in contributes.permissions. Termix registers them as <id>.<name>. defaultRoles
gives them to the built in admin and user roles on first install. You can't claim a core group
(hosts, credentials, admin) or another plugin's. Labels are locale keys.
Translations
Every string goes in locales/en.json, nested by area. Manifest keys (titleKey, labelKey) and t()
read it from your own namespace. Unknown keys fall back to Termix's shared strings like common.save.
Plurals use _one and _other with t("hosts", { count }). Other languages go in
locales/translated/<code>.json (like de_DE.json) with the same keys. Keep {{placeholders}} as they are.
Untranslated strings show in English.
Docs
docs/index.md is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
it. More pages sit next to it with title and order front matter. Plain Markdown, no MDX. Images go in
docs/img/.
Don't write by hand what is generated: features, settings, permissions, capabilities, env vars, services,
the API reference (from @openapi) and the changelog. Write short sentences in plain words and name
buttons exactly as the UI does.
Auth and protocols
Login methods, second factors and SSH auth types need auth:provide, which is high risk and reviewed
closely. Declare ids in contributes.auth and register them with ctx.auth.registerLoginMethod,
registerSecondFactor or registerSshAuthProvider. Connection protocols go in contributes.protocols
and app.registerHostProtocol. Read https://docs.termix.site/develop/auth before starting one.
Testing
// vitest.config.ts
import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
Backend tests go in tests/backend/ (Node), frontend tests in tests/frontend/ (jsdom). Never next to the
source.
Backend, from @termix-ssh/plugin-sdk/testing:
const db = await createTestDb(pluginDir); // in memory SQLite, your migrations, stub users/ssh_data/roles
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities, // calls outside these throw, like the real thing
db: db.database,
router: () => (router = express.Router()),
permissions: ["hello.use"], // leave out to allow everything
});
await activate(mock.ctx);
mock.setActor("user-1");
createMockCtx also takes hosts and services. createFakeContext() is a looser double that skips the
capability checks. The template's tests/backend/routes.test.ts serves the router with Express and calls
it with fetch: start from it.
Frontend:
import { renderWithApp } from "@termix-ssh/plugin-sdk/testing";
import * as plugin from "../../src/frontend/index.js";
const app = await renderWithApp(plugin, {
api: { get: async () => ({ data: [] }) },
});
expect(app.registered.tabs()).toContain("hello");
Test every route, with and without the permission. Test that disable then enable works, and that the plugin works when an optional dependency is missing.
Running it on a server
- You need Node 24 and a Termix server (26.10 or newer) you are an admin on. A local Docker one is fine.
- In Termix, open Settings, General and turn on Plugin developer mode.
- Make an API key in Settings, API keys.
- Run
npx termix-plugin dev --server http://localhost:30001 --key tmx_...(or setTERMIX_SERVER_URLandTERMIX_API_KEY). It builds, installs and reinstalls on every save. Reload the page for frontend changes.--onceinstalls once.
Or npm run build && npm run pack and install the .tmxplug from Plugins, Install from file. Dev installs
are marked unverified. You can't use the id of a plugin that ships with Termix, and a registry copy of the
same plugin must be uninstalled first.
Releasing
Plugins use semver. Work happens on a dev-X.Y.Z branch made from main, named after the version it will
ship as. main only changes when a stable release merges a dev branch into it.
CHANGELOG.md, newest first, no ## Unreleased section and no dates:
# Changelog
## 1.1.0
### Added
- A wave button in the terminal toolbar
### Fixed
- Notes keep their order after a restart
Sections: ### Added, ### Changed, ### Fixed, ### Removed, ### Deprecated, ### Security.
termix-plugin changelog --version 1.1.0 prints one version's notes.
Run the Release workflow by hand from the dev branch on GitHub:
| Choice | What it does |
|---|---|
| stable | Tests, builds and publishes X.Y.Z, merges the branch into main and deletes it |
| beta | Publishes X.Y.Z-beta.N, N counts up on its own |
| overwrite | Releases the manifest version again from this commit |
| dry-run | Builds and packs, publishes nothing |
No secrets are needed. The workflow records GitHub build provenance for the .tmxplug, and the run summary
shows the sha256 for a registry submission. If a dev branch changes .github/workflows, add a
TERMIX_PAT secret (a token with repo and workflow scopes). To also sign with your own key, set
TERMIX_PLUGIN_SIGNING_KEY. Keep .github/workflows/release.yml as the template has it: the community
registry checks provenance from it.
Submitting to the community registry
Before you submit:
- The repo is public with an open source license, made from the template or using its
release.yml. npm test,npm run typecheck,npm run validateandnpm run buildpass.- The
idisn't used by an official plugin or another submission. name,description,author,repository(your repo) anddocs(or a README that explains use) are set.
Then release a stable or beta version and fork https://github.com/Termix-SSH/Termix-Registry. Add
community/plugins/<id>.json:
{
"$schema": "../../community-submission-schema.json",
"id": "my-plugin",
"repository": "https://github.com/you/termix-plugin-my-plugin",
"maintainers": ["your-github-username"],
"versions": [
{ "version": "1.0.0", "sha256": "<from the release run summary>" }
]
}
Open a pull request. The Check submission job downloads the v<version> release and checks the sha256,
that the manifest inside has your id, version and repo, that it has build provenance from the release
workflow, and that you are a maintainer. Then a person reviews the source at the tag and merges it. The
registry signs it with the community key and lists it a few minutes later.
Reviewers check that:
- The source does what the name and description say, and nothing else.
- Nothing is obfuscated or minified, and no code is downloaded and run at runtime.
- There are no network calls, tracking or telemetry the user didn't ask for.
- Capabilities and permissions are the fewest needed.
package.jsonscripts and dependencies don't fetch or run anything unexpected at install or build.- Secrets are only used for what the plugin is for.
- The name and icon don't pretend to be an official plugin.
Every version needs its own PR that adds it to the top of versions. Overwriting a reviewed release drops
it from the registry until a new version is submitted. Admins see the plugin once they turn on the community
registry in Plugins, Registries.
To run your own registry instead, see https://docs.termix.site/develop/registries.
termix-plugin CLI
| Command | What it does |
|---|---|
build |
Bundle dist/backend.js and dist/frontend.js, CSS, locales, migrations, dist/openapi.json |
validate |
Check the manifest, its files, migrations for every database, the changelog and package.json |
test |
Apply patches, run Vitest |
migrations |
Write a migration per database from tables.ts. --check fails when one is missing |
openapi |
Write dist/openapi.json from @openapi blocks |
pack |
Validate, then write <id>-<version>.tmxplug. --out <dir> |
sign <file> |
Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY |
verify <file> --key <base64> |
Check a .tmxplug against its .sig |
keygen |
Write a new signing key pair. --out <dir> |
changelog |
Print one version's notes. --version x.y.z |
patch |
Apply the plugin's dependency patches (build and test do this too) |
dev |
Build, pack and install on a server, again on every change. --server, --key, --once |
SDK entry points
| Import | What it is |
|---|---|
@termix-ssh/plugin-sdk/backend |
PluginContext and the backend types |
@termix-ssh/plugin-sdk/frontend |
TermixApp, TabProps and the hooks |
@termix-ssh/plugin-sdk/ui |
The UI kit, served by Termix at runtime |
@termix-ssh/plugin-sdk/db |
defineTable and column builders |
@termix-ssh/plugin-sdk/manifest |
PluginManifest type and manifest checks |
@termix-ssh/plugin-sdk/capabilities |
The capability catalog |
@termix-ssh/plugin-sdk/settings |
Settings field validation |
@termix-ssh/plugin-sdk/testing |
createMockCtx, createTestDb, renderWithApp, createFakeContext |
@termix-ssh/plugin-sdk/vitest-preset |
pluginVitestConfig |
@termix-ssh/plugin-sdk/host-commands |
OS detection, package manager and sudo helpers over ssh2 |
@termix-ssh/plugin-sdk/ssh-certs |
applyCertificateAuth() for certificate based SSH auth |
@termix-ssh/plugin-sdk/desktop |
The desktop app bridge, typed. Check it exists before use |
@termix-ssh/plugin-sdk/openapi |
buildOpenApi() |
@termix-ssh/plugin-sdk/changelog |
Changelog parsing and checks |
@termix-ssh/plugin-sdk/docs |
Docs link and env var helpers |
@termix-ssh/plugin-sdk/ddl, @termix-ssh/plugin-sdk/table-builder |
Table to SQL and Drizzle mapping. You rarely need these |
Capabilities
| Capability | Risk | What it allows |
|---|---|---|
credentials:read |
critical | Read stored secrets for hosts the user can reach |
system:tls |
critical | Replace the HTTPS certificate, answer CA domain checks |
ssh:connect |
high | Run any command on hosts, with the user's access |
process:spawn |
high | Run programs on the Termix server |
users:write |
high | Add users and change their roles |
users:impersonate |
high | Run background work as another user, audited |
db:core-refs |
high | Query core users, roles and hosts tables (read only) |
auth:provide |
high | Add login methods, second factors, SSH auth types |
device:serial |
high | Open serial and USB ports on the server |
notify:hub |
high | Receive every alert (the alerts hub only) |
hosts:write |
medium | Add and edit hosts |
credentials:use |
medium | Connect with saved credentials without seeing them |
credentials:write |
medium | Save new credentials |
network:outbound |
medium | Outbound HTTP through ctx.fetch |
network:serve |
medium | HTTP routes and sockets |
network:broadcast |
medium | Local network broadcasts, like Wake-on-LAN |
users:read |
medium | Usernames and roles |
events:core |
medium | Listen to core events |
notify:send |
medium | Send alerts |
audit:read |
medium | Read the audit log |
desktop:window |
medium | Open windows in the desktop app |
hosts:read |
low | Read hosts the user can see |
db:own |
low | Your own tables |
kv:own |
low | Small key value state |
files:own |
low | Your own folder on disk |
secrets:own |
low | Your own encrypted secrets |
settings:read-core |
low | Read Termix server settings |
plugins:read |
low | See installed plugins |
ui:surface |
low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
Common mistakes
- Tests fail after
npm install: usually a missing devDependency (@testing-library/dom,jsdom,better-sqlite3) or a newer TypeScript major pulled in by another package. Pintypescriptto the template's range. hello-world,hello_worldorHello Worldleft over from the template inpackage.json, locales, index names, API paths, tab ids or tests.- Changed
tables.tswithout runningnpm run migrations, or kept the template'smigrations/folder after renaming the id. Delete it and runnpm run migrationsonce on a fresh plugin. - A rail item or tab id that isn't declared in
contributes. - A string typed straight into JSX instead of
t(). - Forgetting
await ctx.db.persist()after a write. - Frontend changes not showing during
dev: reload the page. - An id clash with an official plugin: dev installs and registry submissions are refused.
Where to look next
- Docs: https://docs.termix.site/develop
- Official plugins are all open source and built the same way. Plugin-Workspaces is a small one to start from, Plugin-Docker runs commands on hosts, Plugin-Snippets offers a service, Plugin-Automations uses services, Plugin-SSO and Plugin-TOTP add sign in, Plugin-Vault adds an SSH auth type and Plugin-Remote-Desktop adds a protocol. All at https://github.com/Termix-SSH.
- Help: the Discord at https://discord.gg/jVQGdvHDrf. SDK bugs go in https://github.com/Termix-SSH/Termix/issues.