plugin sdk
squash
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
# Slerm TypeScript plugin SDK
|
||||
|
||||
Slerm runs JavaScript bundles in **embedded Boa**, with a separate worker thread
|
||||
and context for each plugin. End users do not need Node.js or Bun. Plugin authors
|
||||
use TypeScript and a bundler during development.
|
||||
|
||||
## Try the example
|
||||
|
||||
From the Slerm repository root:
|
||||
|
||||
```sh
|
||||
npm ci --prefix sdk
|
||||
npm run example --prefix sdk
|
||||
```
|
||||
|
||||
Add this to your Slerm configuration, replacing the absolute path:
|
||||
|
||||
```toml
|
||||
[[plugins]]
|
||||
id = "workspace"
|
||||
path = "/absolute/path/to/slerm/sdk/dist/workspace.js"
|
||||
```
|
||||
|
||||
Start Slerm. The example opens **Workspace tools**, with controls to create a
|
||||
floating pane, run a shell command, and inspect terminal text. Reopen the panel
|
||||
from **Plugins → workspace → Show workspace tools**.
|
||||
|
||||
Bundle paths can also be relative to the configuration file's directory. Plugins
|
||||
load at startup; restart Slerm after changing a bundle or the plugin configuration.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
Build the SDK with `npm run build --prefix sdk`. In a separate plugin project,
|
||||
install the local package with `npm install /absolute/path/to/slerm/sdk` and add
|
||||
TypeScript and esbuild as development dependencies. The package has not been
|
||||
published to a registry.
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@slerm/plugin-sdk";
|
||||
|
||||
definePlugin({
|
||||
async activate(slerm) {
|
||||
await slerm.registerCommand({
|
||||
command: { id: "scratch", title: "New scratch pane" },
|
||||
});
|
||||
},
|
||||
async onEvent(event, slerm) {
|
||||
if (event.event === "command" && event.command === "scratch") {
|
||||
await slerm.execute({ action: "new_floating" });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Type-check your entry point, then bundle it:
|
||||
|
||||
```sh
|
||||
npx tsc --noEmit --strict --target es2022 --module nodenext plugin.ts
|
||||
npx esbuild plugin.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=plugin.js
|
||||
```
|
||||
|
||||
Point a `[[plugins]]` entry at `plugin.js`. Each bundle calls `definePlugin` once.
|
||||
All API calls return promises that resolve after Slerm applies the operation.
|
||||
Events invoke `onEvent(event, slerm)`; callbacks can be async.
|
||||
|
||||
## Generated API
|
||||
|
||||
The Rust definitions in [`src/plugin_api.rs`](../src/plugin_api.rs) are the source
|
||||
of truth. A Rust method list declares parameters and results; `ts-rs` generates
|
||||
`src/generated.ts`, and TypeScript derives the client method signatures from those
|
||||
types. Actions come from Slerm's existing Rust action enum. State, UI elements,
|
||||
and events also use the same Rust types for serialization and type generation.
|
||||
The generic runtime bridge does not duplicate a method list or handwritten types.
|
||||
|
||||
Regenerate after changing the Rust API:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo run -- --generate-plugin-sdk
|
||||
npm test --prefix sdk
|
||||
nix develop -c cargo test
|
||||
nix develop -c cargo test bundled_typescript_example_runs_in_boa -- --ignored
|
||||
```
|
||||
|
||||
Without Nix, run Cargo in an environment containing the project's Rust and Zig
|
||||
build dependencies. Commit `sdk/src/generated.ts` with API changes. A Rust test
|
||||
fails if the checked-in bindings differ from the schema. Bump `API_VERSION` for
|
||||
incompatible changes; Slerm rejects bundles built for another API version.
|
||||
|
||||
| Method | Purpose |
|
||||
| --- | --- |
|
||||
| `getState()` | Read tabs, panes, titles, focus, floating state, and zoom |
|
||||
| `execute({ action })` | Execute a built-in pane, tab, clipboard, or settings action |
|
||||
| `focusPane({ pane })`, `focusTab({ tab })` | Focus by ID from `getState()` |
|
||||
| `readTerminal({ pane })` | Read a plain-text screen and scrollback snapshot, up to 128 KiB |
|
||||
| `writeTerminal({ pane, text })` | Send raw PTY input; include `\r` to submit a shell command |
|
||||
| `registerCommand({ command })`, `unregisterCommand({ command })` | Manage entries in the Plugins menu |
|
||||
| `setPanel({ panel })`, `removePanel({ panel })` | Create, replace, or remove a native panel |
|
||||
| `reportError({ message })` | Display an error in Slerm |
|
||||
|
||||
Command and panel IDs are scoped to the plugin. Control IDs must be unique within
|
||||
a panel. Pane and tab IDs come from Slerm and should be treated as opaque numbers.
|
||||
|
||||
Events are discriminated by `event.event`:
|
||||
|
||||
- `command`: `event.command` is the selected command ID.
|
||||
- `state`: `event.state` is a snapshot when workspace metadata changes. Terminal
|
||||
output changes alone do not produce this event; use `readTerminal` for snapshots.
|
||||
- `ui`: `event.data` contains the panel/control IDs and either a `click`, or a
|
||||
`change` with the new input `value`.
|
||||
|
||||
## Native UI
|
||||
|
||||
`setPanel` renders an egui floating window. Its `elements` accept `text`, `button`,
|
||||
`input`, and `separator` variants. Inputs retain edits until replaced by another
|
||||
`setPanel` call. UI focus receives typing without forwarding it to the terminal.
|
||||
See [`examples/workspace.ts`](examples/workspace.ts) for a complete example.
|
||||
|
||||
## Runtime boundaries
|
||||
|
||||
The runtime supports ECMAScript and promises. This initial host does not expose
|
||||
Node.js APIs, a DOM, filesystem/network APIs, or timers. Bundle imports ahead of
|
||||
time and use libraries compatible with this environment. Report diagnostics with
|
||||
`reportError`; `console` is not installed.
|
||||
|
||||
Plugins are trusted local extensions: terminal writes can run shell commands.
|
||||
Boa contexts run in the same OS process as Slerm; worker threads and execution
|
||||
limits are not an OS security sandbox. Slerm bounds loops, recursion, promise jobs,
|
||||
request waits, bundle/message sizes, and UI counts. An uncaught callback error
|
||||
stops that plugin and removes its commands and panels. Host-operation errors
|
||||
reject the API promise and can be handled by the plugin.
|
||||
|
||||
Implementation references: [Boa](https://boajs.dev/docs/intro) and
|
||||
[ts-rs](https://docs.rs/ts-rs/latest/ts_rs/).
|
||||
@@ -0,0 +1,40 @@
|
||||
import { definePlugin, type Slerm } from "../src/index.js";
|
||||
|
||||
let command = "pwd";
|
||||
const show = (slerm: Slerm) => slerm.setPanel({ panel: {
|
||||
id: "workspace", title: "Workspace tools", elements: [
|
||||
{ type: "text", text: "Create a scratch terminal or run a command in the active pane." },
|
||||
{ type: "button", id: "scratch", label: "New floating pane" },
|
||||
{ type: "separator" },
|
||||
{ type: "input", id: "command", label: "Shell command", value: command },
|
||||
{ type: "button", id: "run", label: "Run in active pane" },
|
||||
{ type: "button", id: "read", label: "Show terminal text" },
|
||||
{ type: "button", id: "close", label: "Close tools" },
|
||||
],
|
||||
} });
|
||||
|
||||
definePlugin({
|
||||
async activate(slerm) {
|
||||
await slerm.registerCommand({ command: { id: "show-tools", title: "Show workspace tools" } });
|
||||
await show(slerm);
|
||||
},
|
||||
async onEvent(event, slerm) {
|
||||
if (event.event === "command" && event.command === "show-tools") await show(slerm);
|
||||
if (event.event !== "ui" || event.data.panel !== "workspace") return;
|
||||
const control = event.data;
|
||||
if (control.kind === "change" && control.control === "command") command = control.value;
|
||||
if (control.kind !== "click") return;
|
||||
if (control.control === "scratch") await slerm.execute({ action: "new_floating" });
|
||||
if (control.control === "close") await slerm.removePanel({ panel: "workspace" });
|
||||
if (control.control === "run" || control.control === "read") {
|
||||
const state = await slerm.getState();
|
||||
const pane = state.tabs.find(tab => tab.id === state.activeTab)?.activePane;
|
||||
if (pane === undefined) return;
|
||||
if (control.control === "run") await slerm.writeTerminal({ pane, text: command + "\r" });
|
||||
else await slerm.setPanel({ panel: {
|
||||
id: "output", title: "Terminal snapshot",
|
||||
elements: [{ type: "text", text: await slerm.readTerminal({ pane }) }],
|
||||
} });
|
||||
}
|
||||
},
|
||||
});
|
||||
Generated
+515
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@slerm/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "Generated TypeScript API for Slerm's embedded Boa plugins",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"generate": "cargo run --manifest-path ../Cargo.toml -- --generate-plugin-sdk",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "npm run build && tsc -p tsconfig.examples.json",
|
||||
"example": "npm run check && esbuild examples/workspace.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=dist/workspace.js",
|
||||
"test": "npm run example && node --test test/*.test.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Generic Boa bridge. API methods and their types come from the Rust schema.
|
||||
(() => {
|
||||
const call = __slerm_call;
|
||||
const fail = __slerm_error;
|
||||
let plugin;
|
||||
const api = new Proxy(Object.create(null), {
|
||||
get: (_, method) => method === "then" ? undefined : async (params = {}) => {
|
||||
const reply = JSON.parse(call(JSON.stringify({ ...params, method })));
|
||||
if (reply.error !== undefined) throw new Error(reply.error);
|
||||
return reply.result;
|
||||
},
|
||||
});
|
||||
const invoke = (callback, ...args) => {
|
||||
if (callback) Promise.resolve().then(() => callback(...args)).catch(error => fail(String(error)));
|
||||
};
|
||||
Object.defineProperty(globalThis, "__slerm", { value: Object.freeze({
|
||||
activate(definition, version) {
|
||||
if (version !== __slerm_version) throw new Error(`Unsupported Slerm API version ${version}`);
|
||||
if (plugin) throw new Error("A bundle must define exactly one plugin");
|
||||
plugin = definition;
|
||||
invoke(plugin.activate, api);
|
||||
},
|
||||
}) });
|
||||
Object.defineProperty(globalThis, "__slerm_dispatch", { value: event => {
|
||||
if (!plugin) throw new Error("Bundle did not call definePlugin");
|
||||
invoke(plugin.onEvent, event, api);
|
||||
} });
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
// Generated by cargo run -- --generate-plugin-sdk. Do not edit.
|
||||
|
||||
export const apiVersion = 1 as const;
|
||||
|
||||
export type Action = "split_right" | "split_down" | "new_floating" | "new_pane" | "toggle_floating" | "toggle_floats" | "next_pane" | "previous_pane" | "focus_left" | "focus_down" | "focus_up" | "focus_right" | "close_pane" | "zoom_pane" | "new_tab" | "next_tab" | "previous_tab" | "reload_config" | "copy" | "paste" | "open_scrollback";
|
||||
|
||||
export type Operation = { "method": "getState", } | { "method": "execute", action: Action, } | { "method": "focusPane", pane: number, } | { "method": "focusTab", tab: number, } | { "method": "readTerminal", pane: number, } | { "method": "writeTerminal", pane: number, text: string, } | { "method": "registerCommand", command: PluginCommand, } | { "method": "unregisterCommand", command: string, } | { "method": "setPanel", panel: Panel, } | { "method": "removePanel", panel: string, } | { "method": "reportError", message: string, };
|
||||
|
||||
export type Results = { getState: State, execute: null, focusPane: null, focusTab: null, readTerminal: string, writeTerminal: null, registerCommand: null, unregisterCommand: null, setPanel: null, removePanel: null, reportError: null, };
|
||||
|
||||
export type State = { activeTab: number | null, tabs: Array<TabState>, };
|
||||
|
||||
export type TabState = { id: number, activePane: number, floatsVisible: boolean, zoomed: boolean, panes: Array<PaneState>, };
|
||||
|
||||
export type PaneState = { id: number, title: string, floating: boolean, exited: boolean, };
|
||||
|
||||
export type PluginCommand = { id: string, title: string, };
|
||||
|
||||
export type Panel = { id: string, title: string, elements: Array<Element>, };
|
||||
|
||||
export type Element = { "type": "text", text: string, } | { "type": "button", id: string, label: string, } | { "type": "input", id: string, label: string, value: string, } | { "type": "separator" };
|
||||
|
||||
export type UIEvent = { "kind": "click", panel: string, control: string, } | { "kind": "change", panel: string, control: string, value: string, };
|
||||
|
||||
export type Event = { "event": "command", command: string, } | { "event": "state", state: State, } | { "event": "ui", data: UIEvent, };
|
||||
|
||||
type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
|
||||
export type Slerm = {
|
||||
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiVersion } from "./generated.js";
|
||||
import type { Event, Slerm } from "./generated.js";
|
||||
export * from "./generated.js";
|
||||
|
||||
export interface Plugin {
|
||||
activate?(slerm: Slerm): void | Promise<void>;
|
||||
onEvent?(event: Event, slerm: Slerm): void | Promise<void>;
|
||||
}
|
||||
|
||||
declare const __slerm: { activate(plugin: Plugin, version: number): void };
|
||||
|
||||
/** Register one plugin in its embedded Boa runtime. Bundle this entry point. */
|
||||
export function definePlugin(plugin: Plugin): void {
|
||||
__slerm.activate(plugin, apiVersion);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext, runInContext } from "node:vm";
|
||||
import test from "node:test";
|
||||
|
||||
const bridge = readFileSync(new URL("../runtime.js", import.meta.url), "utf8");
|
||||
const example = readFileSync(new URL("../dist/workspace.js", import.meta.url), "utf8");
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
function start(reply = () => ({ result: null })) {
|
||||
const operations = [];
|
||||
const errors = [];
|
||||
const context = {
|
||||
__slerm_version: 1,
|
||||
__slerm_call: text => {
|
||||
const operation = JSON.parse(text);
|
||||
operations.push(operation);
|
||||
return JSON.stringify(reply(operation));
|
||||
},
|
||||
__slerm_error: error => errors.push(error),
|
||||
};
|
||||
runInNewContext(bridge, context);
|
||||
return { context, operations, errors };
|
||||
}
|
||||
|
||||
test("bundled TypeScript example uses generated methods and handles UI and command events", async () => {
|
||||
const host = start(operation => ({ result: operation.method === "getState"
|
||||
? { activeTab: 1, tabs: [{ id: 1, activePane: 2 }] }
|
||||
: operation.method === "readTerminal" ? "hello from terminal" : null }));
|
||||
runInContext(example, host.context);
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.map(op => op.method), ["registerCommand", "setPanel"]);
|
||||
assert.equal(host.operations[1].panel.elements[3].type, "input");
|
||||
const ui = data => host.context.__slerm_dispatch({ event: "ui", data: { panel: "workspace", ...data } });
|
||||
ui({ kind: "change", control: "command", value: "echo hello" });
|
||||
await settle();
|
||||
ui({ kind: "click", control: "run" });
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.at(-1), { method: "writeTerminal", pane: 2, text: "echo hello\r" });
|
||||
ui({ kind: "click", control: "scratch" });
|
||||
await settle();
|
||||
assert.deepEqual(host.operations.at(-1), { method: "execute", action: "new_floating" });
|
||||
ui({ kind: "click", control: "read" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).panel.elements[0].text, "hello from terminal");
|
||||
ui({ kind: "click", control: "close" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).method, "removePanel");
|
||||
host.context.__slerm_dispatch({ event: "command", command: "show-tools" });
|
||||
await settle();
|
||||
assert.equal(host.operations.at(-1).method, "setPanel");
|
||||
assert.deepEqual(host.errors, []);
|
||||
});
|
||||
|
||||
test("API mismatch and duplicate registration fail before activation", () => {
|
||||
const { context } = start();
|
||||
assert.throws(() => context.__slerm.activate({}, 2), /Unsupported Slerm API/);
|
||||
context.__slerm.activate({}, 1);
|
||||
assert.throws(() => context.__slerm.activate({}, 1), /exactly one plugin/);
|
||||
});
|
||||
|
||||
test("host errors reject generated calls and surface callback failures", async () => {
|
||||
const host = start(() => ({ error: "unknown pane" }));
|
||||
runInContext(example, host.context);
|
||||
await settle();
|
||||
assert.deepEqual(host.errors, ["Error: unknown pane"]);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Slerm, State } from "../src/index.js";
|
||||
|
||||
declare const api: Slerm;
|
||||
const state: Promise<State> = api.getState();
|
||||
const text: Promise<string> = api.readTerminal({ pane: 1 });
|
||||
void state; void text;
|
||||
// @ts-expect-error Generated methods reject unknown actions.
|
||||
api.execute({ action: "missing" });
|
||||
// @ts-expect-error Pane IDs are numbers in the JSON API.
|
||||
api.focusPane({ pane: "1" });
|
||||
// @ts-expect-error A terminal write requires text.
|
||||
api.writeTerminal({ pane: 1 });
|
||||
// @ts-expect-error Parameterless methods take no parameters.
|
||||
api.getState({});
|
||||
// @ts-expect-error Panel elements are generated from the Rust enum.
|
||||
api.setPanel({ panel: { id: "test", title: "Test", elements: [{ type: "html", html: "" }] } });
|
||||
// @ts-expect-error Return types come from the Rust method list.
|
||||
const invalid: Promise<number> = api.readTerminal({ pane: 1 });
|
||||
void invalid;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true, "rootDir": "." },
|
||||
"include": ["examples/**/*.ts", "test/types.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"types": [],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user