mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
1032 lines
63 KiB
Markdown
1032 lines
63 KiB
Markdown
# Termix plugin guide
|
|
|
|
SDK 1.0.22 | Plugin API 1.13.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 exports `activate(ctx)`.
|
|
- A frontend, `src/frontend/index.tsx`, run in the browser in the same page as Termix. It exports
|
|
`activate(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.
|
|
|
|
1. Import only `@termix-ssh/plugin-sdk/*`, your own files and npm packages. Never import Termix's own code.
|
|
2. Create everything inside `activate`. No servers, timers, sockets or connections at the top of a module.
|
|
Disable then enable runs `activate` again on the same module, and it must work without a restart.
|
|
3. Clean up what you make yourself with `ctx.disposables.add(fn)` (backend) or `app.onDispose(fn)`
|
|
(frontend). Things made through `ctx` and `app` are cleaned up for you.
|
|
4. Never call `process.exit` or touch signals.
|
|
5. Never open your own SSH connection. Use `ctx.ssh` so jump hosts, proxies, host keys, every auth type and
|
|
sharing work the way the user set them up.
|
|
6. Outbound HTTP goes through `ctx.fetch` (needs `network:outbound`), not a raw `fetch` or axios.
|
|
7. Ask for the fewest capabilities you can. A call that needs one you didn't list throws
|
|
`PluginCapabilityError`.
|
|
8. Never take a user id from a request body or a service caller. Use `ctx.currentActor()`.
|
|
9. Check permissions on the backend. Frontend checks only hide UI.
|
|
10. Call `await ctx.db.persist()` after every database write. On SQLite a write without it can be lost on
|
|
restart.
|
|
11. Never use `.returning()` in a query. MySQL doesn't have it. Read a new row back by a key you chose.
|
|
12. Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a
|
|
new one.
|
|
13. Never index a `text()` or `json()` column. Use `varchar(n)` for anything you index.
|
|
14. Never hard code UI text. Every string goes in `locales/en.json` and is read with `t()`.
|
|
15. The plugin `id` never changes once released. It names your tables, routes and permissions.
|
|
16. The version in `manifest.json`, `package.json` and the newest `CHANGELOG.md` section must match.
|
|
17. Put an `@openapi` JSDoc block above every route.
|
|
18. No obfuscated or minified source, and no code downloaded and run at runtime.
|
|
19. 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
|
|
|
|
```bash
|
|
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 (with LICENSE and NOTICE)
|
|
npm run format # Prettier
|
|
npm run format:check
|
|
```
|
|
|
|
## Done checklist
|
|
|
|
Before you call a change finished:
|
|
|
|
- [ ] `npm test` passes
|
|
- [ ] `npm run typecheck` passes
|
|
- [ ] `npm run validate` passes with no warnings you can fix
|
|
- [ ] `npm run build` passes
|
|
- [ ] `npm run format:check` passes
|
|
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
|
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
|
|
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
|
- [ ] New env vars are listed in `env` in the manifest
|
|
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
|
- [ ] User facing changes are in `CHANGELOG.md` under the version they will ship in
|
|
|
|
## Manifest
|
|
|
|
```json
|
|
{
|
|
"$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](https://lucide.dev/icons/) 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`. Older Termix builds don't list a release they can't run. |
|
|
| `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`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
|
|
|
|
### Mobile app
|
|
|
|
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
|
|
plugin as having nothing to show (official plugins are mapped by the app itself).
|
|
|
|
```json
|
|
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
|
|
```
|
|
|
|
| Field | Notes |
|
|
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
|
|
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
|
|
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
|
|
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
|
|
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
|
|
| `note` | A locale key or plain text the app shows next to the plugin. |
|
|
|
|
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
|
|
|
|
How a `webview` plugin runs in the app:
|
|
|
|
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
|
|
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
|
|
plugin list.
|
|
- Every request and socket goes through the app, which adds the login, proxy headers and client
|
|
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
|
|
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
|
|
activity). Other core routes answer 403, so call your own backend instead.
|
|
- Images load straight from the server, without proxy headers or a client certificate.
|
|
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
|
|
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
|
|
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
|
|
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
|
|
is too old for the range, it asks the user to update the app instead of opening the view. When the range
|
|
is for an older API major, it asks to update the plugin. Ask only for the API you use.
|
|
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
|
|
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
|
|
|
|
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
|
|
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
|
|
|
|
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
|
|
|
|
```ts
|
|
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 |
|
|
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
|
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
|
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
|
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
|
|
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
|
|
| `users.getLocale` | `users:read` | A user's UI language |
|
|
| `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.
|
|
|
|
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
|
|
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
|
|
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
|
|
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
|
|
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
|
|
UI language, like `zh_CN`, or null, for writing to them in it.
|
|
|
|
### 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.
|
|
|
|
```ts
|
|
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 `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
|
`dist/openapi.json` and the docs site renders them.
|
|
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
|
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
|
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
|
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
|
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
|
`conn.isDataUnlocked()` per message.
|
|
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
|
`hosts.edit` checks a core permission.
|
|
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
|
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
|
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
|
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
|
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
|
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
|
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
|
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
|
|
`ctx.credentials.list()` finds the acting user's credentials by name.
|
|
|
|
### 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
|
|
|
|
```ts
|
|
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. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
|
|
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
|
|
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
|
|
|
### The desktop app
|
|
|
|
Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
|
|
loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
|
|
`desktop:local` and check `ctx.desktop.local.available()` at runtime.
|
|
|
|
`ctx.desktop.local` works on the desktop app's own backend only:
|
|
|
|
- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
|
|
- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
|
|
`openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
|
|
an existing file is never replaced by accident
|
|
- `pty.spawn()`: a local shell, stopped when the plugin stops
|
|
- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
|
|
|
|
Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
|
|
are called after a line is accepted and never delay the action that wrote it.
|
|
|
|
### Events, timers, alerts, services
|
|
|
|
```ts
|
|
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 `provides` with a version and a permission, then
|
|
`ctx.services.provide("hello.greetings", impl)`. The name starts with your id. Methods read the caller
|
|
from `ctx.currentActor()`.
|
|
- Use a service: declare it in `requires`, then `ctx.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`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
|
|
`peerDependencies`. Any other npm package is bundled.
|
|
|
|
### Workers and WebAssembly
|
|
|
|
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
|
|
each worker entry in `package.json`:
|
|
|
|
```json
|
|
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
|
```
|
|
|
|
`build` bundles it alone into `dist/workers/engine.js`; start it with
|
|
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
|
|
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
|
|
`postMessage`.
|
|
|
|
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
|
|
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
|
|
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
|
|
to a `.d.ts` in the plugin.
|
|
|
|
## Database
|
|
|
|
Needs `db:own`. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL,
|
|
query with [Drizzle](https://orm.drizzle.team/).
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```tsx
|
|
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`) |
|
|
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
|
|
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
|
|
| `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 |
|
|
| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
|
|
| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
|
|
| `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 (`section`, `position`, `replaces`) |
|
|
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
|
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
|
| `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 |
|
|
|
|
Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
|
|
comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
|
|
`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
|
|
those are not shown it again.
|
|
|
|
Branding: a plugin that owns the app name, tagline and logo provides
|
|
`ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
|
|
Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
|
|
back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
|
|
|
|
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
|
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
|
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
|
|
|
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`, `shell.rightEdge` and `tab.menu`. See
|
|
https://docs.termix.site/develop/more-extension-points.
|
|
|
|
## Layouts and themes
|
|
|
|
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
|
admins set the default for everyone.
|
|
|
|
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
|
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
|
it in `activate`:
|
|
|
|
```tsx
|
|
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
|
import {
|
|
ShellConfirmCapture,
|
|
SurfaceScope,
|
|
TabStage,
|
|
} from "@termix-ssh/plugin-sdk/ui";
|
|
|
|
function MyLayout() {
|
|
const shell = useShell();
|
|
if (!shell) return null;
|
|
return (
|
|
<div className="flex h-dvh w-screen flex-col bg-background">
|
|
<header>
|
|
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
|
</header>
|
|
<SurfaceScope kind="tab">
|
|
<ShellConfirmCapture />
|
|
<TabStage />
|
|
</SurfaceScope>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function activate(app: TermixApp) {
|
|
app.registerLayout({
|
|
id: "my-layout",
|
|
label: "layout.name",
|
|
component: MyLayout,
|
|
});
|
|
}
|
|
```
|
|
|
|
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
|
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
|
every session.
|
|
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
|
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
|
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
|
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
|
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
|
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
|
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
|
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
|
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
|
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
|
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
|
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
|
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
|
|
shipped. `?safe-layout=1` opens it any time.
|
|
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
|
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
|
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
|
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
|
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
|
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
|
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
|
|
|
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
|
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
|
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
|
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
|
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
|
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
|
|
|
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
|
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
|
`validateThemeCss()`. Notes:
|
|
|
|
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
|
- The first theme a bundled default-look plugin lists is the default for new installs.
|
|
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
|
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
|
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
|
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
|
|
|
|
## 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).
|
|
|
|
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
|
|
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
|
|
`fallback` in a browser.
|
|
- 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 with `InlineView`, and use
|
|
`PanePrompt` for small prompts.
|
|
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
|
forms end with `FormFooter`, empty lists show `EmptyState`.
|
|
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
|
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
|
|
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
|
|
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
|
- Host sessions use `ConnectionGate` for 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. `build` compiles the ones you use against Termix's theme.
|
|
- Link to your docs with `DocsLink` or `PanelShell`'s `docs` prop, 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 an
|
|
`enableKey` switch 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 and `save`.
|
|
|
|
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.
|
|
|
|
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
|
|
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
|
|
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
|
|
every language.
|
|
|
|
```json
|
|
{
|
|
"plugin": {
|
|
"name": "Host Metrics",
|
|
"description": "Live CPU, memory, disk, network and GPU stats."
|
|
}
|
|
}
|
|
```
|
|
|
|
## 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.
|
|
|
|
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
|
|
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
|
|
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
|
|
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
|
|
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
|
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
|
|
|
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
|
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
|
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
|
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
|
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
|
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
|
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
|
|
|
## Testing
|
|
|
|
```ts
|
|
// 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`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```tsx
|
|
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
|
|
|
|
1. You need Node 24 and a Termix server (26.10 or newer) you are an admin on. A local Docker one is fine.
|
|
2. In Termix, open Settings, General and turn on Plugin developer mode.
|
|
3. Make an API key in Settings, API keys.
|
|
4. Run `npx termix-plugin dev --server http://localhost:30001 --key tmx_...` (or set `TERMIX_SERVER_URL`
|
|
and `TERMIX_API_KEY`). It builds, installs and reinstalls on every save. Reload the page for frontend
|
|
changes. `--once` installs 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:
|
|
|
|
```md
|
|
# 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 validate` and `npm run build` pass.
|
|
- The `id` isn't used by an official plugin or another submission.
|
|
- `name`, `description`, `author`, `repository` (your repo) and `docs` (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`:
|
|
|
|
```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.json` scripts 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`, `dist/frontend.js`, workers, CSS, locales, migrations, `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/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
|
|
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
|
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
|
| `@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 | Act as another user, or get a session for them, 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 |
|
|
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
|
| `sync:provide` | high | Run sync: answer every /sync request |
|
|
| `device:serial` | high | Open serial and USB ports on the server |
|
|
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
|
| `hosts:export` | high | Export hosts with their saved logins |
|
|
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
|
| `ui:layout` | high | Replace the whole app layout |
|
|
| `hosts:write` | medium | Add and edit hosts |
|
|
| `hosts:status-provide` | medium | Be the source of host status dots |
|
|
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
|
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
|
|
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
|
| `network:probe` | medium | Check that a host answers on a TCP port |
|
|
| `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, roles and UI language |
|
|
| `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 |
|
|
| `ui:theme` | low | Add color themes, CSS only |
|
|
|
|
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. Pin `typescript` to the
|
|
template's range.
|
|
- `hello-world`, `hello_world` or `Hello World` left over from the template in `package.json`, locales,
|
|
index names, API paths, tab ids or tests.
|
|
- Changed `tables.ts` without running `npm run migrations`, or kept the template's `migrations/` folder
|
|
after renaming the id. Delete it and run `npm run migrations` once 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.
|