mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: convert serial console into the serial plugin
This commit is contained in:
1 parent
708eedb861
commit
791d8656a1
121 files changed
+2216
-1425
No files matched your search
@@ -1031,6 +1031,7 @@ not do.
|
||||
"frontend": "dist/frontend.js", // default
|
||||
"locales": "locales", // default
|
||||
"platforms": ["linux", "win32", "darwin"], // optional
|
||||
"nativeDependencies": ["serialport"], // optional, see "Native dependencies"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1104,6 +1105,7 @@ deciding, not the mechanism.
|
||||
| `users:write` | high | Create and change user accounts |
|
||||
| `auth:provide` | high | Add a login method, second factor or SSH auth type |
|
||||
| `system:tls` | high | Request and replace the server certificate |
|
||||
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
|
||||
| `hosts:write` | medium | Create and change hosts |
|
||||
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
|
||||
| `network:outbound` | medium | Make outbound requests |
|
||||
@@ -1156,6 +1158,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.sync.registerEntity` | none | **A3** |
|
||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||
@@ -1165,6 +1168,20 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.fetch` | `network:outbound` | B |
|
||||
|
||||
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||
generic check for a capability no other ctx member wraps. Unlike every other
|
||||
row above, the capability it checks is not fixed at the call site: the
|
||||
plugin names it. That is right for exactly one kind of caller - privileged
|
||||
code core cannot mediate through its own primitives, because it is not core's
|
||||
to mediate. The serial plugin bundling `serialport` and opening an OS device
|
||||
file is the first: there is no `ctx.serial.open()` for core to gate, because
|
||||
the whole point is that the plugin talks to the hardware directly. `has`
|
||||
answers without an audit line, for deciding whether to offer something;
|
||||
`require` throws `PluginCapabilityError` and audits like a guarded member. A
|
||||
plugin reaching for this first checks whether its need is actually one of the
|
||||
rows above - `device:serial` is not a general "trust me" capability, it is
|
||||
specifically for code a dedicated ctx member cannot cover.
|
||||
|
||||
`ctx.ssh` was pulled forward from B so plugin transports go through core's
|
||||
connect pipeline instead of importing ssh2 helpers from core:
|
||||
|
||||
@@ -1429,6 +1446,70 @@ it.
|
||||
|
||||
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
|
||||
|
||||
### Native dependencies
|
||||
|
||||
A package with a compiled `.node` binding (`serialport`, `better-sqlite3`,
|
||||
`node-pty`) cannot be bundled the way an ordinary npm dependency is. esbuild
|
||||
would inline its JS, but the binding is loaded through a path computed
|
||||
relative to the original package's own directory, which breaks once that JS
|
||||
moves into `dist/backend.js` somewhere else entirely. It also cannot go on
|
||||
the host-provided list above: that list is for packages every plugin shares
|
||||
one instance of, and a serial port binding is one plugin's business, not
|
||||
core's.
|
||||
|
||||
**B11** (the serial plugin, the first bundled plugin with a native
|
||||
dependency) resolved this by declaring the package in the manifest's
|
||||
`nativeDependencies` array (`["serialport"]`) as well as a real dependency in
|
||||
the plugin's own `package.json`. `termix-plugin build` adds every entry to
|
||||
esbuild's `external` list for that plugin alone, so the compiled bundle keeps
|
||||
a plain `import { SerialPort } from "serialport"` instead of inlining it, and
|
||||
`termix-plugin validate` checks the name is actually declared in the
|
||||
plugin's own `dependencies`.
|
||||
|
||||
At runtime that import is a bare specifier Node resolves by walking up from
|
||||
wherever `dist/backend.js` is loaded, through `dist/plugins/serial/`,
|
||||
`dist/plugins/`, `dist/`, to the repo (or image) root. **The three
|
||||
deployments this repo builds all end up with the package there because of
|
||||
how npm workspaces already install it**, not because of anything specific to
|
||||
native code:
|
||||
|
||||
- **Dev and CI.** `npm install` at the repo root installs every workspace's
|
||||
own dependencies, including a plugin's, and hoists them into the root
|
||||
`node_modules` unless a version conflict forces one to stay nested. Nothing
|
||||
about `serialport` is special here; it lands exactly where any of this
|
||||
plugin's other dependencies would.
|
||||
- **Docker.** The image's `npm ci` runs once at the repo root over the whole
|
||||
workspace and produces one `node_modules`, which is copied into the final
|
||||
image wholesale (`docker/Dockerfile`'s `production-deps` stage). Its native
|
||||
binding compiles against the image's own `node:26-slim` toolchain
|
||||
(`python3 make g++`, already installed for `better-sqlite3`), once per
|
||||
target architecture under `docker buildx`, so linux x64 and arm64 each get
|
||||
a binary actually built for that machine rather than a cross-compiled one.
|
||||
- **Electron.** `electron-builder.json`'s `asarUnpack` already unpacks all of
|
||||
`node_modules` (not a curated subset), and `npm run electron:rebuild` runs
|
||||
`electron-rebuild` over the same hoisted `node_modules`, rebuilding
|
||||
`@serialport/bindings-cpp` against Electron's ABI by name, exactly as it
|
||||
already did for `better-sqlite3` and `node-pty`. Nothing in that script
|
||||
changed: a package's native binding gets rebuilt because the script names
|
||||
it, not because of which `package.json` declared the package as a
|
||||
dependency.
|
||||
|
||||
None of this required a new packaging mechanism, which is the honest reason
|
||||
it was chosen over shipping the plugin its own `node_modules`: this repo's
|
||||
build already treats the whole workspace as one `node_modules`, so a
|
||||
native dependency declared in a plugin's `package.json` rides along for
|
||||
free. A **community plugin installed from a tarball has none of this**: there
|
||||
is no repo-root `npm install` to hoist its dependency into, no shared
|
||||
`node_modules` its bundle can walk up to. Shipping a native dependency in a
|
||||
plugin nobody's build system already vendors is an open problem this step
|
||||
does not solve - see `packages/plugin-sdk/FINISH-LIST.md`. A community plugin
|
||||
author who hits this today has three honest options, worst to best: ask the
|
||||
person installing it to `npm install` the native package into the server's
|
||||
own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a
|
||||
native dependency and reach for a WASM build of the same functionality if one
|
||||
exists; or wait for prebuild-per-platform packaging support, which is not
|
||||
built yet.
|
||||
|
||||
---
|
||||
|
||||
## Converting a feature into a plugin
|
||||
@@ -1797,11 +1878,12 @@ Known specifics:
|
||||
an `optionalDependency` (added in A2; it previously declared nothing, and
|
||||
the loader had no reason to order the two).
|
||||
- Core feature servers that are not plugins yet still own ports: dashboard
|
||||
30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx
|
||||
block until its own Phase B step. No plugin owns a port any more (A4).
|
||||
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`,
|
||||
and **B7** moved tunnels off port 30003 onto `/plugin-api/tunnels/` and
|
||||
`/plugin-ws/tunnels/c2s/stream`.
|
||||
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
|
||||
step. No plugin owns a port any more (A4). **B6** moved file-manager off
|
||||
port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port
|
||||
30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`,
|
||||
**B10** moved tmux monitoring off port 30010, and **B11** moved serial off
|
||||
port 30011 onto `/plugin-ws/serial/console`.
|
||||
- **B9** moved the terminal, its session manager, the local terminal and the
|
||||
command history panel into ssh-terminal, which imports nothing from core.
|
||||
The file manager's terminal window renders the `terminal.view` component
|
||||
|
||||
@@ -247,6 +247,28 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`app.registerAction` opening the singleton tab) but is a shared gap across
|
||||
four plugins, not specific to this step. Owner: D1, or whoever revisits the
|
||||
toolbar's quick-link row.
|
||||
- **B11 (serial):** `plugins/serial/src/frontend/Serial.tsx` uses a small
|
||||
hardcoded light/dark xterm palette and a generic monospace font stack
|
||||
instead of the app's real terminal theme system
|
||||
(`resolveTermixThemeColors`, `DEFAULT_TERMINAL_CONFIG`, `TERMINAL_FONTS`,
|
||||
font loading), which is still core-only (`src/ui/features/terminal/`,
|
||||
`src/ui/lib/terminal-themes.ts`) and not yet exposed through the SDK. The
|
||||
serial console therefore does not match a user's chosen terminal theme or
|
||||
font. Promoting that system into `@termix/plugin-sdk/ui` is really
|
||||
ssh-terminal's own Phase B step's call to make (it is the plugin that
|
||||
currently owns the terminal component itself, per the "Known specifics"
|
||||
note above), so this line just points at the gap rather than building a
|
||||
premature shared surface for one caller. Owner: ssh-terminal's own Phase B
|
||||
step, or D1.
|
||||
- **B11 (serial):** shipping a plugin's own native dependency (`serialport`)
|
||||
works for this repo's three bundled deployments only because they all
|
||||
install the whole npm workspace at once and copy/rebuild one shared
|
||||
`node_modules` - see "Native dependencies" in ARCHITECTURE.md for the full
|
||||
reasoning. A community plugin installed later from a tarball has no such
|
||||
shared `node_modules` to land in, so it cannot ship a native dependency the
|
||||
same way. No fix attempted here; the honest workarounds are listed in that
|
||||
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
|
||||
packaging, or a documented "avoid native code" rule for community plugins).
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
@@ -254,3 +276,5 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view,
|
||||
snippets sent to a terminal, reconnect after a network drop and after a
|
||||
page reload (session reattach), and the local terminal in the desktop app.
|
||||
- Serial: the Electron backend path against a real device path/COM port, and
|
||||
the Web Serial browser picker in Chrome or Edge.
|
||||
@@ -52,7 +52,12 @@ export async function build({ cwd }) {
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: BACKEND_EXTERNALS,
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function validate({ cwd }) {
|
||||
}
|
||||
|
||||
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -46,6 +47,33 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A native dependency has to actually be a real dependency of the plugin, or
|
||||
* the build's external declaration points at nothing node_modules can
|
||||
* resolve at runtime.
|
||||
*/
|
||||
function validateNativeDependencies(cwd, raw) {
|
||||
const problems = [];
|
||||
const nativeDependencies = raw.nativeDependencies ?? [];
|
||||
if (nativeDependencies.length === 0) return problems;
|
||||
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const deps = { ...pkg.dependencies };
|
||||
|
||||
for (const name of nativeDependencies) {
|
||||
if (!(name in deps)) {
|
||||
problems.push(
|
||||
`nativeDependencies names "${name}", which is not in this plugin's own package.json dependencies`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
const DIALECTS = ["sqlite", "postgres", "mysql"];
|
||||
|
||||
const CREATE_OR_ALTER =
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"users:write",
|
||||
"auth:provide",
|
||||
"system:tls",
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
"credentials:use",
|
||||
"network:outbound",
|
||||
@@ -742,6 +743,15 @@
|
||||
"type": "string",
|
||||
"enum": ["linux", "win32", "darwin"]
|
||||
}
|
||||
},
|
||||
"nativeDependencies": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Bare npm package names this plugin depends on that carry a native (.node) binding. The build never bundles them: they stay a real dependency in the plugin's own package.json and are resolved from node_modules at runtime, the same as a host-provided package, so the compiled native binary is never copied into the JS bundle.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
|
||||
@@ -1111,6 +1111,21 @@ export interface PluginAudit {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic capability check, for a privileged action that has no dedicated
|
||||
* ctx method to wrap it: a plugin bundling its own native dependency to touch
|
||||
* hardware (a serial port, a USB device) is the first caller. Core cannot
|
||||
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
|
||||
* capability here is a declared, reviewable, audited statement of intent
|
||||
* rather than a technical gate, matching credentials:read and process:spawn.
|
||||
*/
|
||||
export interface PluginCapabilities {
|
||||
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
|
||||
has: (capability: string) => Promise<boolean>;
|
||||
/** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */
|
||||
require: (capability: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -1127,6 +1142,8 @@ export interface PluginContext {
|
||||
readonly rbac: PluginRbac;
|
||||
readonly settings: PluginSettings;
|
||||
readonly disposables: PluginDisposables;
|
||||
/** A generic capability check, for privileged code no ctx method wraps. */
|
||||
readonly capabilities: PluginCapabilities;
|
||||
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
|
||||
readonly hosts: PluginHosts;
|
||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||
|
||||
@@ -45,6 +45,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:write", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("system:tls", "high"),
|
||||
entry("device:serial", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("credentials:use", "medium"),
|
||||
|
||||
@@ -232,6 +232,12 @@ export interface TabOptions {
|
||||
singleton?: boolean;
|
||||
/** A live session: asks before closing and can be refreshed. */
|
||||
session?: boolean;
|
||||
/**
|
||||
* Whether a session tab offers "Share session". Default true. A plugin
|
||||
* whose ref does not implement session sharing (a local device, not a
|
||||
* remote connection another user could join) sets this false.
|
||||
*/
|
||||
shareable?: boolean;
|
||||
/** Restorable without a host. */
|
||||
hostless?: boolean;
|
||||
/** Decides whether a saved tab for this host may be restored. */
|
||||
|
||||
@@ -298,6 +298,15 @@ export interface PluginManifest {
|
||||
/** Locales directory, relative to the plugin root. */
|
||||
locales?: string;
|
||||
platforms?: string[];
|
||||
/**
|
||||
* Bare npm package names carrying a native (.node) binding. The CLI build
|
||||
* never bundles them: they stay a real dependency in the plugin's own
|
||||
* package.json and are resolved from node_modules at runtime instead,
|
||||
* because esbuild bundling a native addon breaks the relative path it uses
|
||||
* to locate its compiled binary. See ARCHITECTURE.md under "native
|
||||
* dependencies".
|
||||
*/
|
||||
nativeDependencies?: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
||||
@@ -331,6 +340,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
"frontend",
|
||||
"locales",
|
||||
"platforms",
|
||||
"nativeDependencies",
|
||||
]);
|
||||
|
||||
const ALLOWED_CONTRIBUTES = new Set([
|
||||
@@ -441,6 +451,7 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
validateCategory(m.category, errors);
|
||||
validateCapabilities(m.capabilities, errors);
|
||||
validatePlatforms(m.platforms, errors);
|
||||
validateNativeDependencies(m.nativeDependencies, errors);
|
||||
validateDependencyMap(m.dependencies, "dependencies", errors);
|
||||
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
||||
validateProvides(m.provides, errors);
|
||||
@@ -535,6 +546,21 @@ function validatePlatforms(platforms: unknown, errors: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function validateNativeDependencies(value: unknown, errors: string[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(
|
||||
'Field "nativeDependencies" must be a non-empty array when present',
|
||||
);
|
||||
return;
|
||||
}
|
||||
value.forEach((entry, index) => {
|
||||
if (typeof entry !== "string" || entry.length === 0) {
|
||||
errors.push(`nativeDependencies[${index}] must be a non-empty string`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateDependencyMap(
|
||||
value: unknown,
|
||||
field: string,
|
||||
|
||||
@@ -406,6 +406,13 @@ export function createFakeContext(
|
||||
}) as PluginMiddleware,
|
||||
},
|
||||
|
||||
// Nothing here is capability-checked, per the module doc; createMockCtx
|
||||
// gates this against options.capabilities.
|
||||
capabilities: {
|
||||
has: async () => true,
|
||||
require: async () => {},
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: async (key) =>
|
||||
settings.get(settingsKey("admin", undefined, key)) as never,
|
||||
@@ -936,6 +943,13 @@ export function createMockCtx(
|
||||
return ctx.desktop.openIsolatedWindow(request);
|
||||
},
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
has: async (capability) => granted.has(capability),
|
||||
require: async (capability) => {
|
||||
require(capability);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { ...base, ctx: gatedCtx, checked };
|
||||
|
||||
Reference in new issue
Block a user