mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: rebuild workspaces as the reference plugin
This commit is contained in:
1 parent
d774df8b41
commit
f217d8db89
79 files changed
+32995
-2184
No files matched your search
@@ -5,7 +5,9 @@ anyone writing a plugin and the specification the runtime in
|
||||
`src/backend/plugins/` implements.
|
||||
|
||||
Status: the redesign lands across several steps. Everything marked **A1** is
|
||||
built today. Later markers name the step that delivers the rest.
|
||||
built today. Later markers name the step that delivers the rest. Phase B
|
||||
converts one feature per step by following
|
||||
[Converting a feature into a plugin](#converting-a-feature-into-a-plugin).
|
||||
|
||||
---
|
||||
|
||||
@@ -24,17 +26,18 @@ This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-
|
||||
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
|
||||
dependencies. Entry points:
|
||||
|
||||
| Entry | Contents |
|
||||
| --------------------------------- | ---------------------------------------------------------- |
|
||||
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
|
||||
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
|
||||
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
|
||||
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
|
||||
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
|
||||
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
||||
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
||||
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
||||
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
|
||||
| Entry | Contents |
|
||||
| ---------------------------------- | ---------------------------------------------------------- |
|
||||
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
|
||||
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
|
||||
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
|
||||
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
|
||||
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
|
||||
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
|
||||
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
|
||||
| `@termix/plugin-sdk/capabilities` | The capability catalog |
|
||||
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
|
||||
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
|
||||
|
||||
Plugins never import from `src/` or `@/`, and core never imports from
|
||||
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
|
||||
@@ -135,11 +138,38 @@ plugin failed and core keeps booting.
|
||||
|
||||
**Adoption** is how a feature keeps its rows when it moves out of core. The
|
||||
legacy table is renamed into the plugin's namespace
|
||||
(`fleets` -> `p_fleets_fleets`) rather than copied, so nothing is duplicated
|
||||
and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps each legacy
|
||||
table to the one plugin allowed to adopt it, enforced by the CLI at build time
|
||||
and by the runner at activation, because a plugin installed from a tarball
|
||||
never ran the CLI.
|
||||
(`user_workspaces` -> `p_workspaces_workspaces`) rather than copied, so nothing
|
||||
is duplicated and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps
|
||||
each legacy table to the one plugin allowed to adopt it, enforced by
|
||||
`adoptLegacyTable`, by the CLI at build time and by the runner at activation,
|
||||
because a plugin installed from a tarball never ran the CLI.
|
||||
|
||||
A plugin marks a definition with `adoptLegacyTable("user_workspaces",
|
||||
defineTable(...))` and `termix-plugin migrations` writes the adoption for all
|
||||
three dialects. A plain SQL file cannot ask whether the legacy table exists, so
|
||||
the migration creates it when it is missing (a fresh install), creates the
|
||||
definition's indexes on it with `IF NOT EXISTS`, and then renames it. Both
|
||||
paths end with the same table. Two rules follow:
|
||||
|
||||
- **Keep the legacy column and index names** in the definition. The rename
|
||||
carries the old indexes across, and a matching name is what makes
|
||||
`IF NOT EXISTS` skip them instead of adding a second copy.
|
||||
- **MySQL gets no index statements** in an adoption, because it has no
|
||||
`CREATE INDEX IF NOT EXISTS`. That is safe for every legacy table core ever
|
||||
shipped to MySQL, since core's drizzle migrations already created its indexes.
|
||||
|
||||
A MySQL `TEXT` column cannot take a literal default, so the emitter writes one
|
||||
as an expression (`DEFAULT ('{}')`), the way it already did for
|
||||
`CURRENT_TIMESTAMP`.
|
||||
|
||||
**Write through `ctx.db.client()`, then call `ctx.db.persist()`.** On SQLite the
|
||||
database lives in memory and reaches its encrypted file only when something
|
||||
asks, which core's repositories do after every write. A plugin write that skips
|
||||
`persist()` can be lost on restart. It is a no-op on Postgres and MySQL, needs
|
||||
`db:own`, and is not audited, because it follows a `client()` call that was.
|
||||
`ctx.db.dialect` says which engine is running. The table object encodes the
|
||||
same on all three, but MySQL has no `RETURNING`, so portable code reads a new
|
||||
row back by a key it chose (a sync id) rather than relying on `.returning()`.
|
||||
|
||||
**Anything DDL-only in `db/index.ts` is SQLite-only.** `alert_rules`,
|
||||
`alert_rule_channels` and `alert_firings` were created only there and never
|
||||
@@ -155,6 +185,11 @@ existing tombstones and rows on the far side match on those strings. The
|
||||
Electron client asks the server for the ordered list and falls back to its
|
||||
frozen array for a server that predates the endpoint.
|
||||
|
||||
`shouldSync(row)` leaves rows out in both directions: they are not pulled, and
|
||||
a push, update or tombstone aimed at one is refused with 400. It exists for
|
||||
state that belongs to one install, such as the workspaces plugin's "Last
|
||||
Session" row, which would otherwise leave a user with one from each side.
|
||||
|
||||
### 6. HTTP and WebSockets
|
||||
|
||||
Plugin routes live under `/plugin-api/<id>/` and sockets under
|
||||
@@ -350,25 +385,26 @@ when the plugin deactivates. Every registered component is wrapped in the
|
||||
plugin's scope (so the hooks know which plugin they belong to), an error
|
||||
boundary and Suspense.
|
||||
|
||||
| Member | What it does |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `registerRailItem` | A rail button. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
|
||||
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
|
||||
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
|
||||
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
|
||||
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
|
||||
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
|
||||
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
| Member | What it does |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
|
||||
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
|
||||
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
|
||||
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
|
||||
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
|
||||
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
|
||||
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
|
||||
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
|
||||
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
|
||||
@@ -391,6 +427,11 @@ needs the <name> plugin" (`PluginViewPlaceholder`) and is never dropped from
|
||||
the saved layout. Tab restore waits for the first plugin pass, so a restored
|
||||
tab does not race its plugin.
|
||||
|
||||
The same holds when a saved layout is applied (a workspace): a tab type no
|
||||
running plugin has registered is reopened as that placeholder rather than
|
||||
skipped, and it stays in the next snapshot (`isUnregisteredPluginTabType` in
|
||||
`src/ui/shell/shell-layout.ts`). Only a tab whose host was deleted is skipped.
|
||||
|
||||
#### The `ui` entry
|
||||
|
||||
`@termix/plugin-sdk/ui` is public API, implemented by
|
||||
@@ -401,7 +442,8 @@ select, select2, separator, switch, textarea, tooltip, section-card,
|
||||
metric-card, charts, the card grid, `ConnectionScreen` and the connection
|
||||
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
|
||||
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
|
||||
`useTabs`/`useTabsSafe`, `ActionSlot` and `ComponentSlot`. Publishing its
|
||||
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot` and `FOLDER_COLORS` (the
|
||||
colour swatches folders and workspaces pick from). Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
|
||||
#### Strings
|
||||
@@ -426,6 +468,12 @@ and frontend actions and slots. Dependencies are declared in the manifest:
|
||||
in dependency order. A plugin must keep working when an optional dependency is
|
||||
missing.
|
||||
|
||||
A service call runs as the user its permission was checked for, so the
|
||||
provider reads that user from `ctx.currentActor()` and takes no user id from
|
||||
the caller. The workspaces plugin provides `workspaces.saved` (`list()`), which
|
||||
the AI assistant's `list_workspaces` tool reaches through an optional entry in
|
||||
its `requires`, answering `unavailable: true` while workspaces is off.
|
||||
|
||||
### 10. Lifecycle
|
||||
|
||||
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
|
||||
@@ -455,8 +503,26 @@ capability, registry, service, secret and shutdown suites.
|
||||
|
||||
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
|
||||
capabilities the way the runtime does, `createFakeContext()` for tests that do
|
||||
not care about the gates, and `renderWithApp(plugin, options)` for frontend
|
||||
tests. `renderWithApp` activates the plugin against core's real registries,
|
||||
not care about the gates, `createTestDb(pluginDir)` for a real database, and
|
||||
`renderWithApp(plugin, options)` for frontend tests.
|
||||
|
||||
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
|
||||
`users` and `ssh_data` tables and foreign keys on, runs `before` (seed a legacy
|
||||
table there to test an adoption), then applies the plugin's own
|
||||
`migrations/sqlite` with the runner's splitter. Pass its `database` to
|
||||
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
|
||||
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
||||
`() => express.Router()`, so routes are served for real), `permissions`
|
||||
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything),
|
||||
and returns `setActor(userId)` for a test middleware and `services` for what the
|
||||
plugin provided. `plugins/workspaces/tests/backend/helpers.ts` is the worked
|
||||
example.
|
||||
|
||||
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
|
||||
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
|
||||
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
|
||||
the shell's real restore rules, `openedTabs()` lists what it opened, and
|
||||
`renderOpenedTab(i)` renders one the way the shell would, placeholder included. `renderWithApp` activates the plugin against core's real registries,
|
||||
records what it registered and the shell calls it made, renders any tab,
|
||||
panel, card, host editor section, settings component or slot, and
|
||||
`deactivate()` disposes it all. Every bundled plugin has a
|
||||
@@ -930,6 +996,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||
| `ctx.currentActor()` | none | **A1** |
|
||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
|
||||
| `ctx.sync.registerEntity` | none | **A3** |
|
||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||
@@ -1101,9 +1168,315 @@ The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## Converting a feature into a plugin
|
||||
|
||||
The checklist every Phase B step follows. A9 ran it on workspaces, so
|
||||
`plugins/workspaces/` is the worked example for each step: read its source and
|
||||
tests next to this list. Steps are ordered so data is never dropped before it
|
||||
has moved. Commands run from the repo root unless they start with `cd`.
|
||||
|
||||
A conversion is done when the plugin imports nothing from `src/` or `@/`, core
|
||||
imports nothing of the feature, every piece of existing data survives an
|
||||
upgrade on all three engines, and the checks in step 15 are green.
|
||||
|
||||
### 1. Scaffold
|
||||
|
||||
1. Create `plugins/<id>/` with the layout in [Where plugins live](#13-where-plugins-live).
|
||||
The directory name is the manifest `id`. If A2 already moved the feature
|
||||
there, keep the directory and work through the rest in place.
|
||||
2. `package.json` is `@termix-plugin/<id>`, private, with the four scripts
|
||||
(`build`, `test`, `typecheck`, `validate`) calling `termix-plugin`, and
|
||||
`@termix/plugin-sdk` as a dev dependency. `vitest.config.ts` is the one-line
|
||||
preset.
|
||||
3. `tsconfig.json` extends `@termix/plugin-sdk/tsconfig.plugin.json` and has
|
||||
**no `paths`** into `../../src`. A path alias is how core imports sneak back.
|
||||
4. Run `npm install` so the workspace is linked.
|
||||
|
||||
### 2. Manifest and capabilities
|
||||
|
||||
1. Fill in the manifest (see the [reference](#manifest-v2-reference)). Declare
|
||||
every view the frontend registers in `contributes.panels`, `tabs` or
|
||||
`dashboardCards`, or `app` refuses to register it.
|
||||
2. Pick the **smallest** set of capabilities from the
|
||||
[catalog](#capability-catalog). Choose by the ctx members the code calls,
|
||||
not by what the feature feels like:
|
||||
|
||||
| The code calls | Declare |
|
||||
| ---------------------------------------- | -------------------------------- |
|
||||
| `ctx.db.*` | `db:own` |
|
||||
| `ctx.kv.*` | `kv:own` |
|
||||
| `ctx.http.router`, `ctx.ws.*` | `network:serve` |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` |
|
||||
| `ctx.auth.*` | `auth:provide` |
|
||||
| `ctx.settings.readCore` | `settings:read-core` |
|
||||
| `ctx.events.emit` outside `plugin.<id>.` | `events:core` |
|
||||
| anything registered through `app` | `ui:surface` |
|
||||
|
||||
Workspaces needs `db:own`, `network:serve` and `ui:surface`, nothing else.
|
||||
|
||||
3. `cd plugins/<id> && npx termix-plugin validate` must print `ok`.
|
||||
|
||||
### 3. Backend: replace every core import with the SDK
|
||||
|
||||
1. List what the backend reaches in core:
|
||||
`grep -rn "src/backend\|src/types" plugins/<id>/src`. Each hit gets replaced.
|
||||
A plugin never imports `../../../../src/...`.
|
||||
2. The usual replacements:
|
||||
|
||||
| Core import | SDK replacement |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `AuthManager` middleware, `req.userId` | nothing: core authenticates `ctx.http` routes; read the user with `ctx.currentActor()` |
|
||||
| `requirePermission(...)` | `ctx.rbac.require("<short name>")` |
|
||||
| `databaseLogger`, `*Logger` | `ctx.log` |
|
||||
| `createCurrent*Repository()` and `schema.ts` tables | the plugin's own table through `ctx.db` (step 4) |
|
||||
| `insertReturning` / `.returning()` | insert, then select by a key you set (a sync id) |
|
||||
| `DatabaseSaveTrigger`, a repository write hook | `await ctx.db.persist()` after every write |
|
||||
| ssh2 connection code, the pool, `resolveHostById` | `ctx.ssh.connect` / `withConnection` / `jumpChain` |
|
||||
| `getCurrentSettingValue` | `ctx.settings.get` for its own keys (step 5), `readCore` for core's |
|
||||
| another plugin's source | `ctx.services.get(...)` (step 10) |
|
||||
|
||||
3. Everything the backend creates lives inside `activate(ctx)`: the table
|
||||
(`ctx.db.define`), the repository, the router, timers
|
||||
(`ctx.disposables.add`). Nothing at module scope. `deactivate` is usually
|
||||
empty.
|
||||
4. **When the SDK does not have it, add it to the SDK.** Never work around a
|
||||
gap with a core import, and never add to
|
||||
`src/backend/auth/legacy-providers.ts`. A new ctx member is:
|
||||
1. the type and a doc comment in `packages/plugin-sdk/src/backend.ts`;
|
||||
2. the implementation in `src/backend/plugins/ctx.ts`, wrapped in `guarded()`
|
||||
when it is privileged, so it checks the capability and writes an audit
|
||||
line; a new capability is one entry in `src/capabilities.ts` plus its
|
||||
i18n title and consequence under `plugins.capabilities.<id>` in
|
||||
`src/ui/locales/en.json`;
|
||||
3. the same member in `createFakeContext` and a gated one in `createMockCtx`
|
||||
(`packages/plugin-sdk/src/testing.ts`);
|
||||
4. a core test in `src/backend/tests/plugins/` (allowed and refused);
|
||||
5. a row in [The ctx surface](#the-ctx-surface) and a line in the right
|
||||
section of this document.
|
||||
|
||||
Then `npm run build:sdk`, because core and the CLI read the SDK's `dist`.
|
||||
A9 added `ctx.db.persist`, `ctx.db.dialect`, sync `shouldSync`,
|
||||
`createTestDb`, `app.t`, `app.hasPermission` and the rail `permission` this
|
||||
way.
|
||||
|
||||
### 4. Tables: adopt them, then remove them from core
|
||||
|
||||
1. Write `src/backend/tables.ts`. Wrap each definition that replaces a core
|
||||
table in `adoptLegacyTable("<legacy name>", defineTable(...))` and export
|
||||
`tables`. Keep the legacy column names (camelCase properties become
|
||||
snake_case columns) and **the legacy index names**. The table must be listed
|
||||
under this plugin in `LEGACY_TABLE_OWNERS` (`packages/plugin-sdk/src/db.ts`).
|
||||
Use `refUser()` / `refHost()` for owner columns: they cascade on delete.
|
||||
2. `cd plugins/<id> && npx termix-plugin migrations adopt_<table>` writes
|
||||
`migrations/{sqlite,postgres,mysql}/0001_adopt_<table>.sql` and
|
||||
`migrations/snapshot.json`. Read all three files. Never edit one after it
|
||||
has shipped; add a new migration instead.
|
||||
3. Remove the table from core, in this order:
|
||||
- `src/backend/database/db/schema.ts`: the table block.
|
||||
- `src/backend/database/db/index.ts`: the `CREATE TABLE` bootstrap block and
|
||||
any `ALTER` for it. Some of these files use CRLF line endings; keep them.
|
||||
- `src/backend/database/db/performance-indexes.ts`: its entries. The adopted
|
||||
definition's indexes replace them.
|
||||
- `src/backend/database/repositories/factory.ts` and the repository file.
|
||||
- `src/backend/database/routes/delete-user-data.ts` and the user/host
|
||||
repositories: explicit deletes of the table. A `refUser`/`refHost` column
|
||||
cascades on every engine, because the foreign key survives the rename.
|
||||
Prefer that cascade. A table that cannot carry such a column needs a
|
||||
core "user deleted" or "host deleted" event on `ctx.events`, added to
|
||||
the SDK the step 3 way; none exists yet because no plugin needed one.
|
||||
- `sync-entities.ts`, if core synced it (step 9).
|
||||
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
|
||||
core tests and `src/types`.
|
||||
4. `npm run schema:generate` (the pg and mysql schema variants), then
|
||||
`npm run schema:migrations`. drizzle-kit writes a `DROP TABLE` for each
|
||||
removed table in `drizzle/{sqlite,postgres,mysql}/`. **Replace every one of
|
||||
those statements with a comment and `SELECT 1;`** (MySQL refuses an empty
|
||||
query) and keep the new `meta` snapshots. The drop would run at boot, before
|
||||
the plugin's adoption, and delete every row.
|
||||
5. Why the order is safe: core boots first, runs its drizzle migrations (the
|
||||
legacy table is still there, untouched), then the plugin's migration renames
|
||||
it. A plugin that is disabled at upgrade time simply leaves the legacy table
|
||||
alone until it is enabled.
|
||||
6. Tests (step 13) prove the adoption with the legacy table present and absent.
|
||||
|
||||
### 5. Global settings keys become plugin settings
|
||||
|
||||
1. Declare each key in `contributes.settings.admin` (install-wide) or `.user`,
|
||||
keeping the key name the frontend already sends where possible, and give it
|
||||
a `default`. Secrets are `type: "secret"`.
|
||||
2. Write a boot migration in `src/backend/utils/crypto-migration/`, modelled on
|
||||
`tailscale-settings-migration.ts`: read the old row from `settings` (or the
|
||||
per-user source), write it to `plugin_settings` for the plugin **only when
|
||||
no row exists there yet**, then leave the old row in place for one release.
|
||||
Call it from the boot sequence in `starter.ts` after the plugins table exists.
|
||||
3. Test it in `src/backend/tests/utils/`: values move, secrets stay encrypted,
|
||||
and running it twice changes nothing.
|
||||
4. Switch the plugin to `ctx.settings.get` / `getUser`, delete the core
|
||||
settings route and UI for those keys, and remove the keys from
|
||||
`CORE_SETTINGS_ALLOWLIST` if they were there.
|
||||
|
||||
### 6. `ssh_data` host columns become host-scope settings
|
||||
|
||||
Follow [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin)
|
||||
exactly: declare `contributes.settings.host`, copy with an idempotent migration
|
||||
tested by running it twice, switch the plugin to `ctx.settings.getHost` and
|
||||
delete the hardcoded editor tab, and only then drop the column in lockstep
|
||||
across `schema.ts`, `db/index.ts`, `schema:generate` and a drizzle migration per
|
||||
dialect. Copy and drop can ship in different steps; a column core still reads
|
||||
is not ready to drop.
|
||||
|
||||
### 7. Permissions
|
||||
|
||||
1. Declare `contributes.permissions` with `titleKey` and `descriptionKey` in the
|
||||
plugin's own locales. **Keep existing ids**: core registers `<id>.<name>`, so
|
||||
pick the short name that makes the id equal the old core one (`ai` + `use` is
|
||||
`ai.use`) and no role needs migrating.
|
||||
2. Remove those entries from `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS` in
|
||||
`src/backend/utils/permission-catalog.ts`, and move their i18n.
|
||||
3. `defaultRoles` are `admin` and/or `user`, applied once.
|
||||
4. Gate every route. `router.use(ctx.rbac.require("<name>"))` before the routes
|
||||
is the simplest way to make sure none is missed; workspaces declares `use`
|
||||
and gates the whole router on it.
|
||||
5. Gate the frontend too: `permission` on the rail item, `usePermission` in
|
||||
components, `await app.hasPermission(...)` before background calls (the
|
||||
workspaces autosave), so a user without it collects no 403s.
|
||||
|
||||
### 8. HTTP, WebSockets and nginx
|
||||
|
||||
1. Routes go on `ctx.http.router()` (served at `/plugin-api/<id>/`) and sockets
|
||||
on `ctx.ws.route` / `ctx.ws.upgrade` (`/plugin-ws/<id>/<path>`). Route paths
|
||||
are relative to the mount point.
|
||||
2. Update every route's OpenAPI JSDoc path to `/plugin-api/<id>/...`.
|
||||
3. Delete the feature's own port and server from core: its import in
|
||||
`src/backend/starter.ts`, its `listen`, and its `location` blocks in **both**
|
||||
`docker/nginx.conf` and `docker/nginx-https.conf`. `/plugin-api/` and
|
||||
`/plugin-ws/` are already proxied.
|
||||
4. Point the frontend at the plugin client (step 11); old paths such as
|
||||
`/workspaces` stop existing.
|
||||
|
||||
### 9. Sync entities
|
||||
|
||||
If core synced the data, or it is personal configuration that should follow a
|
||||
user between the desktop app and a server, register it in `activate`:
|
||||
`ctx.sync.registerEntity({ type, table, order, ... })`. Keep an existing wire
|
||||
name exactly and remove core's registration from `sync-entities.ts`. Leave
|
||||
`electron/remote-sync-entities.cjs` alone: it is the fallback for servers
|
||||
older than the entity-types endpoint, which still sync the core entity. Give
|
||||
an entity that references others a higher `order` than they have. Use `shouldSync` for rows that belong to one
|
||||
install (workspaces leaves out its `last_session` row).
|
||||
|
||||
### 10. Cross-plugin and core callers
|
||||
|
||||
1. `grep -rn` the old repository or module across `src/` and `plugins/`. A core
|
||||
caller means the feature is not fully out yet: move the logic behind a core
|
||||
extension point or into the plugin.
|
||||
2. Another plugin calling it gets a service: declare it in `provides`
|
||||
(`<id>.<noun>`, a version and a permission), `ctx.services.provide` it in
|
||||
`activate`, and have the caller declare an optional `requires` entry plus
|
||||
`optionalDependencies`. The caller must work when it is absent (catch and
|
||||
degrade). Workspaces provides `workspaces.saved` to the AI plugin.
|
||||
|
||||
### 11. Frontend
|
||||
|
||||
1. List core imports:
|
||||
`grep -rn "from \"@/\|src/ui" plugins/<id>/src/frontend`. Replace each:
|
||||
|
||||
| Core import | SDK replacement |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `@/components/*` | `@termix/plugin-sdk/ui` |
|
||||
| `authApi`, `handleApiError` from `@/main-axios` | `app.api` or `usePluginApi()`, paths relative to the plugin |
|
||||
| `react-i18next`, `i18next.t` | `useTranslation()` in components, `app.t` elsewhere |
|
||||
| `usePermissions` | `usePermission` / `app.hasPermission` |
|
||||
| shell modules (`workspaceUtils`, tab state) | `app.tabs`, `useTabs` |
|
||||
| `@/types/*` | the plugin's own `types.ts` |
|
||||
| small helpers (`getErrorMessage`) | a few lines in the plugin |
|
||||
|
||||
Something the plugin genuinely needs from the shell's component library goes
|
||||
into `src/ui/plugin-host/sdk-ui.ts` (a contract change: list it under
|
||||
[The `ui` entry](#the-ui-entry)).
|
||||
|
||||
2. Register everything in `activate(app)`: rail item (with `permission` when
|
||||
there is one), panel, tabs, host editor sections, actions and slot
|
||||
contributions. Use `app.onDispose` for timers.
|
||||
3. Remove the shell's references: imports from the feature, hardcoded ids and
|
||||
switch cases, and the matching entries in
|
||||
`scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs`
|
||||
must pass.
|
||||
4. The rail item appears in Appearance > Sidebar > Navigation on its own
|
||||
because it is registered; confirm it there.
|
||||
5. No hardcoded text (a relative time is a translated string), no em dashes,
|
||||
square containers.
|
||||
|
||||
### 12. Locales
|
||||
|
||||
1. Move every key the plugin uses from `src/ui/locales/en.json` into
|
||||
`plugins/<id>/locales/en.json`, keeping the key paths so the code changes
|
||||
only its namespace. Keys still used by core stay in core; a plugin may keep
|
||||
using core's `common.*`, which is the fallback.
|
||||
2. Move the same keys, per language, from `src/ui/locales/translated/<xx_YY>.json`
|
||||
into `plugins/<id>/locales/translated/<xx_YY>.json`, so no translation is lost.
|
||||
Crowdin already picks up `plugins/*/locales/en.json` (`crowdin.yml`).
|
||||
3. Manifest keys (`titleKey`, `labelKey`) are plugin-relative.
|
||||
4. Only `en.json` is edited by hand afterwards.
|
||||
|
||||
### 13. Tests
|
||||
|
||||
1. Move the feature's tests to `plugins/<id>/tests/backend` and
|
||||
`tests/frontend`. **No plugin test imports `src/`**, and no core test imports
|
||||
`plugins/`. Delete core tests of code that left core.
|
||||
2. The minimum a conversion ships:
|
||||
- routes against a real database: `createTestDb(pluginDir)` +
|
||||
`createMockCtx({ db, router: () => express.Router(), permissions })` +
|
||||
`activate`, served on an ephemeral port and called with `fetch`
|
||||
(`plugins/workspaces/tests/backend/helpers.ts`);
|
||||
- permission denial on every route;
|
||||
- the adoption with the legacy table present (rows and indexes survive) and
|
||||
absent (fresh table), using `createTestDb`'s `before` hook with the legacy
|
||||
DDL copied from `db/index.ts`;
|
||||
- activate failing closed without each capability;
|
||||
- a frontend `activate.test.tsx` on `renderWithApp`, plus the panel or tab
|
||||
rendering against a stubbed `api`.
|
||||
3. Every settings or host-column migration gets its run-twice test in core.
|
||||
4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
|
||||
the end.
|
||||
|
||||
### 14. Boundary allowlist
|
||||
|
||||
`node scripts/check-plugin-boundaries.cjs --write`, then
|
||||
`git diff scripts/plugin-boundary-allowlist.json`: the plugin's entries are
|
||||
gone and **no other line was added**. Update the counts in the table under
|
||||
[Legacy core imports](#legacy-core-imports-the-debt-d1-removes).
|
||||
|
||||
### 15. Build, test and check by hand
|
||||
|
||||
```bash
|
||||
npm run build:sdk
|
||||
(cd plugins/<id> && npx termix-plugin validate && npx termix-plugin migrations --check)
|
||||
npm run build:backend
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run test:plugins
|
||||
```
|
||||
|
||||
Then, with the app running:
|
||||
|
||||
1. Boot on an existing SQLite database from before the conversion: the data is
|
||||
there, the `p_<id>_` tables exist and the legacy tables are gone.
|
||||
2. Use every feature path once: create, edit, delete, anything background.
|
||||
3. Remove the plugin's permission from a role: the rail item disappears and
|
||||
the API answers 403.
|
||||
4. Disable, enable and disable the plugin in admin without a restart: its
|
||||
views show the "needs the plugin" placeholder while off and come back after.
|
||||
5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot.
|
||||
6. Update this document for anything the step added to the contract.
|
||||
|
||||
---
|
||||
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The twelve bundled plugins predate the SDK. They still reach core by relative
|
||||
The bundled plugins predate the SDK, apart from workspaces, which A9 rebuilt
|
||||
as the reference conversion and which imports nothing from core. They still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
@@ -1130,8 +1503,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 109 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 70 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 106 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 68 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
|
||||
@@ -81,6 +81,7 @@ function snapshotTable(definition) {
|
||||
return {
|
||||
columns: definition.columns,
|
||||
indexes: definition.indexes,
|
||||
...(definition.adopts ? { adopts: definition.adopts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,7 +156,21 @@ export async function migrations({ cwd, args }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { createTableSql, addColumnSql } = await loadEmitter();
|
||||
const { LEGACY_TABLE_OWNERS } = await import(
|
||||
new URL("../../dist/db.js", import.meta.url).href
|
||||
);
|
||||
for (const definition of definitions) {
|
||||
if (
|
||||
definition.adopts &&
|
||||
LEGACY_TABLE_OWNERS[definition.adopts] !== pluginId
|
||||
) {
|
||||
throw new Error(
|
||||
`${pluginId}: table ${definition.name} adopts "${definition.adopts}", which belongs to the "${LEGACY_TABLE_OWNERS[definition.adopts]}" plugin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { createTableSql, adoptTableSql, addColumnSql } = await loadEmitter();
|
||||
|
||||
const previous = readSnapshot(cwd);
|
||||
const { created, added, manual } = diff(previous, definitions);
|
||||
@@ -184,7 +199,13 @@ export async function migrations({ cwd, args }) {
|
||||
for (const dialect of DIALECTS) {
|
||||
const statements = [];
|
||||
for (const definition of created) {
|
||||
statements.push(...createTableSql(dialect, pluginId, definition));
|
||||
// An adopted table renames the legacy one into place, creating it first
|
||||
// on a fresh install, so existing rows are kept rather than copied.
|
||||
statements.push(
|
||||
...(definition.adopts
|
||||
? adoptTableSql(dialect, pluginId, definition)
|
||||
: createTableSql(dialect, pluginId, definition)),
|
||||
);
|
||||
}
|
||||
for (const entry of added) {
|
||||
statements.push(
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
"types": "./dist/ddl.d.ts",
|
||||
"default": "./dist/ddl.js"
|
||||
},
|
||||
"./table-builder": {
|
||||
"types": "./dist/table-builder.d.ts",
|
||||
"default": "./dist/table-builder.js"
|
||||
},
|
||||
"./frontend": {
|
||||
"types": "./dist/frontend.d.ts",
|
||||
"default": "./dist/frontend.js"
|
||||
@@ -65,5 +69,13 @@
|
||||
"esbuild": "^0.28.1",
|
||||
"semver": "^7.6.3",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"drizzle-orm": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,18 @@ export interface PluginDatabase {
|
||||
client: <T = unknown>() => Promise<T>;
|
||||
/** Read-only references to the core tables a plugin may point at. */
|
||||
refs: <T = unknown>() => Promise<T>;
|
||||
/**
|
||||
* Flushes writes to disk. Call it after every write: on SQLite the database
|
||||
* lives in memory and is saved to its encrypted file only when asked, so a
|
||||
* write without it can be lost on restart. A no-op on Postgres and MySQL.
|
||||
*/
|
||||
persist: () => Promise<void>;
|
||||
/**
|
||||
* The engine the server runs on. Table objects encode the same on all
|
||||
* three, but MySQL has no RETURNING, so portable code reads a written row
|
||||
* back by its key rather than relying on .returning().
|
||||
*/
|
||||
readonly dialect: "sqlite" | "postgres" | "mysql";
|
||||
}
|
||||
|
||||
/** A row's shape on the wire, before it is written to a table. */
|
||||
@@ -124,6 +136,11 @@ export interface SyncEntityRegistration {
|
||||
readOnlyFields?: readonly string[];
|
||||
/** One row per user rather than many, keyed on the owner. */
|
||||
singleton?: boolean;
|
||||
/**
|
||||
* Rows this returns false for are left out of sync in both directions, for
|
||||
* state that belongs to one install (a per-device "last session").
|
||||
*/
|
||||
shouldSync?: (row: SyncRow) => boolean;
|
||||
/**
|
||||
* Escape hatch for a row whose references are not plain columns, such as ids
|
||||
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface PluginTableDefinition {
|
||||
readonly name: string;
|
||||
readonly columns: Readonly<Record<string, PluginColumn>>;
|
||||
readonly indexes: readonly PluginTableIndex[];
|
||||
/** A legacy core table this definition takes over by rename. See adoptLegacyTable. */
|
||||
readonly adopts?: string;
|
||||
}
|
||||
|
||||
/** Columns that can never carry an index, because MySQL cannot index them. */
|
||||
@@ -261,3 +263,25 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
|
||||
webauthn_credentials: "webauthn",
|
||||
sso_providers: "sso",
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks a definition as the new home of a legacy core table.
|
||||
*
|
||||
* The migration the CLI writes for it renames the legacy table into the
|
||||
* plugin's namespace, and creates it first when there is none (a fresh
|
||||
* install), so both paths end with the same table. Keep the legacy column and
|
||||
* index names in the definition: the rename carries the old indexes across,
|
||||
* and reusing their names is what stops the migration from creating a second
|
||||
* copy of each.
|
||||
*/
|
||||
export function adoptLegacyTable(
|
||||
legacyName: string,
|
||||
definition: PluginTableDefinition,
|
||||
): PluginTableDefinition {
|
||||
if (!Object.prototype.hasOwnProperty.call(LEGACY_TABLE_OWNERS, legacyName)) {
|
||||
throw new Error(
|
||||
`"${legacyName}" is not a legacy core table a plugin may adopt. See LEGACY_TABLE_OWNERS.`,
|
||||
);
|
||||
}
|
||||
return { ...definition, adopts: legacyName };
|
||||
}
|
||||
+137
-12
@@ -81,6 +81,14 @@ function columnType(
|
||||
}
|
||||
}
|
||||
|
||||
/** Column types that are TEXT on MySQL when nothing keys them. */
|
||||
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
|
||||
"text",
|
||||
"json",
|
||||
"encryptedText",
|
||||
"timestamp",
|
||||
]);
|
||||
|
||||
function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
|
||||
if (column.defaultNow) {
|
||||
// MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column; an
|
||||
@@ -98,7 +106,12 @@ function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
|
||||
: ` DEFAULT ${value ? "true" : "false"}`;
|
||||
}
|
||||
if (typeof value === "number") return ` DEFAULT ${value}`;
|
||||
return ` DEFAULT '${String(value).replace(/'/g, "''")}'`;
|
||||
const literal = `'${String(value).replace(/'/g, "''")}'`;
|
||||
// MySQL refuses a literal default on a TEXT column but takes an expression.
|
||||
if (dialect === "mysql" && TEXT_ON_MYSQL.has(column.type)) {
|
||||
return ` DEFAULT (${literal})`;
|
||||
}
|
||||
return ` DEFAULT ${literal}`;
|
||||
}
|
||||
|
||||
/** Every column that carries a key, and so must be indexable on MySQL. */
|
||||
@@ -136,13 +149,11 @@ function indexStatement(
|
||||
return `CREATE ${unique}INDEX ${guard}${quote(dialect, entry.name)} ON ${quote(dialect, physical)} (${columns});`;
|
||||
}
|
||||
|
||||
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
|
||||
export function createTableSql(
|
||||
function createStatements(
|
||||
dialect: SqlDialect,
|
||||
pluginId: string,
|
||||
physical: string,
|
||||
definition: PluginTableDefinition,
|
||||
): string[] {
|
||||
const physical = prefixedTableName(pluginId, definition.name);
|
||||
): { table: string; indexes: string[] } {
|
||||
const keyed = keyedColumns(definition);
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -178,18 +189,132 @@ export function createTableSql(
|
||||
}
|
||||
|
||||
const body = [...lines, ...constraints].join(",\n");
|
||||
const statements = [
|
||||
`CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
|
||||
];
|
||||
return {
|
||||
table: `CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
|
||||
indexes: definition.indexes.map((entry) =>
|
||||
indexStatement(dialect, physical, definition, entry),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of definition.indexes) {
|
||||
statements.push(indexStatement(dialect, physical, definition, entry));
|
||||
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
|
||||
export function createTableSql(
|
||||
dialect: SqlDialect,
|
||||
pluginId: string,
|
||||
definition: PluginTableDefinition,
|
||||
): string[] {
|
||||
const physical = prefixedTableName(pluginId, definition.name);
|
||||
const { table, indexes } = createStatements(dialect, physical, definition);
|
||||
return [table, ...indexes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over a legacy core table by renaming it into the plugin's namespace.
|
||||
*
|
||||
* A plain SQL migration cannot ask whether the legacy table exists, so it
|
||||
* creates it first when it does not (a fresh install) and then renames either
|
||||
* way. Both paths end with the same table and the same rows.
|
||||
*
|
||||
* Indexes are created on the legacy name with IF NOT EXISTS before the rename,
|
||||
* which is why an adopted definition keeps the legacy index names: an index
|
||||
* that already exists is left alone and follows the table. MySQL has no
|
||||
* CREATE INDEX IF NOT EXISTS, so there none are emitted. That is safe because
|
||||
* every legacy table reached MySQL through core's drizzle migrations, which
|
||||
* already created its indexes.
|
||||
*/
|
||||
export function adoptTableSql(
|
||||
dialect: SqlDialect,
|
||||
pluginId: string,
|
||||
definition: PluginTableDefinition,
|
||||
): string[] {
|
||||
const legacy = definition.adopts;
|
||||
if (!legacy) {
|
||||
throw new Error(`Table "${definition.name}" does not adopt a legacy table`);
|
||||
}
|
||||
const physical = prefixedTableName(pluginId, definition.name);
|
||||
const { table, indexes } = createStatements(dialect, legacy, definition);
|
||||
|
||||
const rename =
|
||||
dialect === "mysql"
|
||||
? `RENAME TABLE ${quote(dialect, legacy)} TO ${quote(dialect, physical)};`
|
||||
: `ALTER TABLE ${quote(dialect, legacy)} RENAME TO ${quote(dialect, physical)};`;
|
||||
|
||||
return [table, ...(dialect === "mysql" ? [] : indexes), rename];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a migration file into statements.
|
||||
*
|
||||
* Deliberately simple: statements end at a semicolon that is not inside a
|
||||
* string literal or a comment. A plugin needing more than that should put the
|
||||
* logic in its own code, not in DDL.
|
||||
*/
|
||||
export function splitStatements(source: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = "";
|
||||
let quoteChar: string | null = null;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
const next = source[i + 1];
|
||||
|
||||
if (lineComment) {
|
||||
current += char;
|
||||
if (char === "\n") lineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockComment) {
|
||||
current += char;
|
||||
if (char === "*" && next === "/") {
|
||||
current += next;
|
||||
i++;
|
||||
blockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quoteChar) {
|
||||
current += char;
|
||||
// '' inside a quoted string is an escaped quote, not a terminator.
|
||||
if (char === quoteChar) {
|
||||
if (next === quoteChar) {
|
||||
current += next;
|
||||
i++;
|
||||
} else {
|
||||
quoteChar = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
lineComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
quoteChar = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
/** DROP statement for a definition, for removePluginData. */
|
||||
/** DROP statement for a definition, for removePluginData. */
|
||||
export function dropTableSql(
|
||||
dialect: SqlDialect,
|
||||
|
||||
@@ -129,6 +129,12 @@ export interface RailItemContribution {
|
||||
separatorAfter?: boolean;
|
||||
/** Hidden without being unregistered, e.g. while a feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* A permission the user needs to see the item at all, usually one of the
|
||||
* plugin's own short names. The rail, the mobile bar, the palette and the
|
||||
* Navigation toggles all leave it out otherwise.
|
||||
*/
|
||||
permission?: string;
|
||||
/** Place it after this rail id rather than at the end. */
|
||||
after?: string;
|
||||
/** Lower sorts first among plugin items. */
|
||||
@@ -503,6 +509,18 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
|
||||
/**
|
||||
* Translates a key from the plugin's own locales, for code outside a
|
||||
* component (a toast from activate). Components use useTranslation().
|
||||
*/
|
||||
t: TranslateFn;
|
||||
/**
|
||||
* Whether the current user holds a permission, for code outside a
|
||||
* component. A short name resolves to <id>.<name>, as usePermission does.
|
||||
* Resolves false before sign-in and when the lookup fails.
|
||||
*/
|
||||
hasPermission: (permission: string) => Promise<boolean>;
|
||||
|
||||
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
|
||||
api: PluginApiClient;
|
||||
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Turns a plugin's table definition into the Drizzle table it is queried
|
||||
* through.
|
||||
*
|
||||
* One table object for every dialect. The sqlite-core definitions already
|
||||
* encode correctly on each engine at query time, so the repositories use them
|
||||
* everywhere; only DDL genuinely differs, and that lives in the SDK's ddl
|
||||
* entry so the CLI and the server share one mapping.
|
||||
*/
|
||||
|
||||
import {
|
||||
sqliteTable,
|
||||
text as sqliteText,
|
||||
integer as sqliteInteger,
|
||||
index as sqliteIndex,
|
||||
uniqueIndex as sqliteUniqueIndex,
|
||||
type AnySQLiteColumn,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { prefixedTableName } from "./db.js";
|
||||
import type { PluginTableDefinition } from "./db.js";
|
||||
import { columnName } from "./ddl.js";
|
||||
|
||||
type ColumnChain = {
|
||||
notNull: () => unknown;
|
||||
primaryKey: () => unknown;
|
||||
unique: () => unknown;
|
||||
default: (value: unknown) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the queryable table object.
|
||||
*
|
||||
* Always sqlite-core, on every dialect. drizzle's three table classes share no
|
||||
* base class, and the query-builder surface a plugin uses is identical across
|
||||
* them.
|
||||
*/
|
||||
export function buildTable(
|
||||
pluginId: string,
|
||||
definition: PluginTableDefinition,
|
||||
): unknown {
|
||||
const physical = prefixedTableName(pluginId, definition.name);
|
||||
|
||||
const columns: Record<string, unknown> = {};
|
||||
for (const [property, column] of Object.entries(definition.columns)) {
|
||||
const name = columnName(property, column);
|
||||
let built: unknown;
|
||||
|
||||
switch (column.type) {
|
||||
case "id":
|
||||
built = sqliteInteger(name).primaryKey({ autoIncrement: true });
|
||||
break;
|
||||
case "integer":
|
||||
case "bigint":
|
||||
case "refHost":
|
||||
built = sqliteInteger(name);
|
||||
break;
|
||||
case "boolean":
|
||||
built = sqliteInteger(name, { mode: "boolean" });
|
||||
break;
|
||||
default:
|
||||
built = sqliteText(name);
|
||||
break;
|
||||
}
|
||||
|
||||
if (column.type !== "id") {
|
||||
if (column.primaryKey) built = (built as ColumnChain).primaryKey();
|
||||
if (column.notNull) built = (built as ColumnChain).notNull();
|
||||
if (column.unique) built = (built as ColumnChain).unique();
|
||||
if (column.defaultNow) {
|
||||
built = (built as ColumnChain).default(sql`CURRENT_TIMESTAMP`);
|
||||
} else if (column.defaultValue !== undefined) {
|
||||
built = (built as ColumnChain).default(column.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
columns[property] = built;
|
||||
}
|
||||
|
||||
return sqliteTable(physical, columns as never, (table) =>
|
||||
definition.indexes.map((entry) => {
|
||||
const make = entry.unique ? sqliteUniqueIndex : sqliteIndex;
|
||||
const target = table as unknown as Record<string, AnySQLiteColumn>;
|
||||
return make(entry.name).on(
|
||||
...(entry.columns.map((c) => target[c]) as [
|
||||
AnySQLiteColumn,
|
||||
...AnySQLiteColumn[],
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal stand-in for a core table, for tests that join against users or
|
||||
* ssh_data without core's schema. Only what createTestDb's stubs declare.
|
||||
*/
|
||||
export function buildRefTable(
|
||||
name: string,
|
||||
columns: Record<string, "text" | "integer">,
|
||||
): unknown {
|
||||
const built: Record<string, unknown> = {};
|
||||
for (const [property, type] of Object.entries(columns)) {
|
||||
built[property] =
|
||||
type === "integer" ? sqliteInteger(property) : sqliteText(property);
|
||||
}
|
||||
return sqliteTable(name, built as never);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
PluginSshHost,
|
||||
} from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
@@ -37,6 +38,16 @@ export interface FakeContextOptions {
|
||||
coreSettings?: Record<string, string>;
|
||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||
sshClient?: unknown;
|
||||
/** A real database behind ctx.db, usually createTestDb().database. */
|
||||
db?: PluginDatabase;
|
||||
/** What ctx.http.router returns, e.g. () => express.Router(). */
|
||||
router?: () => unknown;
|
||||
/**
|
||||
* Role permissions the acting user holds, as full ids or this plugin's
|
||||
* short names. When set, ctx.rbac enforces them and require() answers 403;
|
||||
* when omitted every check passes, so tests that do not care stay simple.
|
||||
*/
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
@@ -71,6 +82,27 @@ export interface FakePluginContext {
|
||||
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
setActor: (userId: string | undefined) => void;
|
||||
/** Implementations provided through ctx.services.provide, by service name. */
|
||||
services: Map<string, object>;
|
||||
}
|
||||
|
||||
const CORE_PERMISSION_GROUPS = new Set([
|
||||
"hosts",
|
||||
"snippets",
|
||||
"credentials",
|
||||
"admin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A short name takes the plugin's prefix. An id already under this plugin or
|
||||
* a core group is used as given, like src/backend/plugins/rbac.ts does.
|
||||
*/
|
||||
function qualifyPermission(pluginId: string, permission: string): string {
|
||||
const head = permission.split(".")[0];
|
||||
if (head === pluginId || CORE_PERMISSION_GROUPS.has(head)) return permission;
|
||||
return `${pluginId}.${permission}`;
|
||||
}
|
||||
|
||||
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
|
||||
@@ -114,6 +146,7 @@ export function createFakeContext(
|
||||
);
|
||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const services = new Map<string, object>();
|
||||
const auth: FakeAuthRegistrations = {
|
||||
sshAuthProviders: [],
|
||||
loginMethods: [],
|
||||
@@ -122,6 +155,15 @@ export function createFakeContext(
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
const held = options.permissions
|
||||
? new Set(
|
||||
options.permissions.map((permission) =>
|
||||
qualifyPermission(pluginId, permission),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const holds = (permission: string) =>
|
||||
held === null || held.has(qualifyPermission(pluginId, permission));
|
||||
|
||||
for (const [key, value] of Object.entries(options.settings ?? {})) {
|
||||
settings.set(settingsKey("admin", undefined, key), value);
|
||||
@@ -184,20 +226,38 @@ export function createFakeContext(
|
||||
list: async () => [...kv.keys()],
|
||||
},
|
||||
|
||||
db: {
|
||||
// No engine behind this one. A test that needs real SQL should drive the
|
||||
// runtime rather than the double.
|
||||
define: async (definition) => {
|
||||
tables.push(definition);
|
||||
return undefined as never;
|
||||
},
|
||||
client: async () => {
|
||||
throw new Error("createFakeContext does not implement db.client");
|
||||
},
|
||||
refs: async () => {
|
||||
throw new Error("createFakeContext does not implement db.refs");
|
||||
},
|
||||
},
|
||||
db: options.db
|
||||
? {
|
||||
...options.db,
|
||||
define: async (definition) => {
|
||||
tables.push(definition);
|
||||
return options.db!.define(definition);
|
||||
},
|
||||
client: () => options.db!.client(),
|
||||
refs: () => options.db!.refs(),
|
||||
persist: () => options.db!.persist(),
|
||||
dialect: options.db.dialect,
|
||||
}
|
||||
: {
|
||||
// No engine behind this one. Pass createTestDb().database as `db`
|
||||
// for a test that needs real SQL.
|
||||
define: async (definition) => {
|
||||
tables.push(definition);
|
||||
return undefined as never;
|
||||
},
|
||||
client: async () => {
|
||||
throw new Error(
|
||||
"createFakeContext has no database: pass createTestDb().database as db",
|
||||
);
|
||||
},
|
||||
refs: async () => {
|
||||
throw new Error(
|
||||
"createFakeContext has no database: pass createTestDb().database as db",
|
||||
);
|
||||
},
|
||||
persist: async () => {},
|
||||
dialect: "sqlite",
|
||||
},
|
||||
|
||||
sync: {
|
||||
registerEntity: (entity) => {
|
||||
@@ -212,9 +272,17 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
services: {
|
||||
provide: () => {},
|
||||
get: () => {
|
||||
throw new Error("createFakeContext does not implement services.get");
|
||||
provide: (service, implementation) => {
|
||||
services.set(service, implementation);
|
||||
},
|
||||
// Only services this same context provided. A test of a consumer
|
||||
// should provide a stub first.
|
||||
get: (service) => {
|
||||
const implementation = services.get(service);
|
||||
if (!implementation) {
|
||||
throw new Error(`No service "${service}" was provided in this test`);
|
||||
}
|
||||
return implementation as never;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -229,7 +297,7 @@ export function createFakeContext(
|
||||
// plugin's own route module against its own express app.
|
||||
router: (routerOptions) => {
|
||||
httpRouters.push(routerOptions);
|
||||
return undefined as never;
|
||||
return (options.router ? options.router() : undefined) as never;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -243,11 +311,22 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
rbac: {
|
||||
// Passes everything through: a test asserting a permission gate should
|
||||
// drive PermissionManager, not this double.
|
||||
has: async () => true,
|
||||
hasFor: async () => true,
|
||||
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
||||
// Enforces options.permissions for the acting user, or passes
|
||||
// everything when none were given. The runtime's own resolution against
|
||||
// other plugins' namespaces is covered by core's tests, not here.
|
||||
has: async (permission) => holds(permission),
|
||||
hasFor: async (_userId, permission) => holds(permission),
|
||||
require: (permission) =>
|
||||
((_req, res, next) => {
|
||||
if (holds(permission)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
error: "Insufficient permissions",
|
||||
required: qualifyPermission(pluginId, permission),
|
||||
});
|
||||
}) as PluginMiddleware,
|
||||
},
|
||||
|
||||
settings: {
|
||||
@@ -379,6 +458,10 @@ export function createFakeContext(
|
||||
coreSettings,
|
||||
sshConnections,
|
||||
auth,
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
},
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,6 +480,12 @@ export interface MockContextOptions {
|
||||
actor?: string;
|
||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||
sshClient?: unknown;
|
||||
/** A real database behind ctx.db, usually createTestDb().database. */
|
||||
db?: PluginDatabase;
|
||||
/** What ctx.http.router returns, e.g. () => express.Router(). */
|
||||
router?: () => unknown;
|
||||
/** Role permissions the acting user holds. See FakeContextOptions. */
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -427,6 +516,9 @@ export function createMockCtx(
|
||||
settings: options.settings,
|
||||
coreSettings: options.coreSettings,
|
||||
sshClient: options.sshClient,
|
||||
db: options.db,
|
||||
router: options.router,
|
||||
permissions: options.permissions,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -465,6 +557,11 @@ export function createMockCtx(
|
||||
require("db:own");
|
||||
return guardedDb.refs();
|
||||
},
|
||||
persist: async () => {
|
||||
require("db:own");
|
||||
return guardedDb.persist();
|
||||
},
|
||||
dialect: guardedDb.dialect,
|
||||
},
|
||||
|
||||
kv: {
|
||||
@@ -585,6 +682,131 @@ export function createMockCtx(
|
||||
return { ...base, ctx: gatedCtx, checked };
|
||||
}
|
||||
|
||||
/** A sqlite handle as createTestDb exposes it. Structural, like better-sqlite3's. */
|
||||
export interface TestSqlite {
|
||||
exec: (sql: string) => unknown;
|
||||
prepare: (sql: string) => {
|
||||
run: (...params: unknown[]) => unknown;
|
||||
get: (...params: unknown[]) => unknown;
|
||||
all: (...params: unknown[]) => unknown[];
|
||||
};
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface TestDbOptions {
|
||||
/**
|
||||
* Runs after the core stub tables exist and before the plugin's migrations,
|
||||
* e.g. to create the legacy table an adoption migration renames.
|
||||
*/
|
||||
before?: (sqlite: TestSqlite) => void | Promise<void>;
|
||||
/** Skip applying the migrations, to apply them by hand later. */
|
||||
skipMigrations?: boolean;
|
||||
}
|
||||
|
||||
export interface TestDb {
|
||||
/** The raw better-sqlite3 handle, for asserting on tables directly. */
|
||||
sqlite: TestSqlite;
|
||||
/** The Drizzle handle ctx.db.client() returns. */
|
||||
drizzle: unknown;
|
||||
/** Pass this as createMockCtx({ db }). */
|
||||
database: PluginDatabase;
|
||||
/** How many times ctx.db.persist() was called. */
|
||||
readonly persisted: number;
|
||||
/** The migration ids applied, in order. */
|
||||
applied: string[];
|
||||
/** Applies the plugin's sqlite migrations, when skipMigrations was set. */
|
||||
migrate: () => Promise<string[]>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory SQLite database with a plugin's own migrations applied.
|
||||
*
|
||||
* Core's users and ssh_data exist as minimal stubs with foreign keys on, so a
|
||||
* refUser or refHost column cascades exactly as it does on the server. The
|
||||
* migrations are read from <pluginDir>/migrations/sqlite and split with the
|
||||
* same splitter the server's runner uses.
|
||||
*/
|
||||
export async function createTestDb(
|
||||
pluginDir: string,
|
||||
options: TestDbOptions = {},
|
||||
): Promise<TestDb> {
|
||||
const [{ default: Database }, { drizzle }, fs, path, ddl, builder] =
|
||||
await Promise.all([
|
||||
import("better-sqlite3" as string),
|
||||
import("drizzle-orm/better-sqlite3" as string),
|
||||
import("node:fs"),
|
||||
import("node:path"),
|
||||
import("./ddl.js"),
|
||||
import("./table-builder.js"),
|
||||
]);
|
||||
|
||||
const sqlite = new Database(":memory:") as TestSqlite;
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
|
||||
);
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
|
||||
|
||||
const handle = drizzle(sqlite);
|
||||
const pluginId = path.basename(pluginDir);
|
||||
const applied: string[] = [];
|
||||
let persisted = 0;
|
||||
|
||||
await options.before?.(sqlite);
|
||||
|
||||
const migrate = async () => {
|
||||
const dir = path.join(pluginDir, "migrations", "sqlite");
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((file: string) => /^\d{4}_[a-z0-9_]+\.sql$/.test(file))
|
||||
.sort();
|
||||
const now: string[] = [];
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.sql$/, "");
|
||||
if (applied.includes(id)) continue;
|
||||
const source = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const statement of ddl.splitStatements(source)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
applied.push(id);
|
||||
now.push(id);
|
||||
}
|
||||
return now;
|
||||
};
|
||||
|
||||
if (!options.skipMigrations) await migrate();
|
||||
|
||||
const refs = {
|
||||
users: builder.buildRefTable("users", { id: "text", username: "text" }),
|
||||
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
|
||||
};
|
||||
|
||||
const database: PluginDatabase = {
|
||||
define: async (definition) =>
|
||||
builder.buildTable(pluginId, definition) as never,
|
||||
client: async () => handle as never,
|
||||
refs: async () => refs as never,
|
||||
persist: async () => {
|
||||
persisted += 1;
|
||||
},
|
||||
dialect: "sqlite",
|
||||
};
|
||||
|
||||
return {
|
||||
sqlite,
|
||||
drizzle: handle,
|
||||
database,
|
||||
get persisted() {
|
||||
return persisted;
|
||||
},
|
||||
applied,
|
||||
migrate,
|
||||
close: () => sqlite.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering a plugin frontend in a test.
|
||||
*
|
||||
@@ -607,6 +829,12 @@ export interface RenderWithAppOptions {
|
||||
locales?: Record<string, unknown>;
|
||||
/** Render as an anonymous guest page. */
|
||||
guest?: boolean;
|
||||
/** What app.tabs.getLayout returns until the plugin applies another. */
|
||||
layout?: import("./frontend.js").ShellLayout;
|
||||
/** Fire app.tabs.onReady after activation, as the shell does after login. */
|
||||
ready?: boolean;
|
||||
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
|
||||
api?: import("./frontend.js").PluginApiClient;
|
||||
}
|
||||
|
||||
/** A call a plugin made on the shell, recorded instead of performed. */
|
||||
@@ -619,7 +847,12 @@ export interface RenderedPluginApp {
|
||||
app: TermixApp;
|
||||
/** Everything this plugin registered, by kind. */
|
||||
registered: {
|
||||
railItems: () => Array<{ id: string; hidden?: boolean }>;
|
||||
railItems: () => Array<{
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
/** The full permission id the item is gated on, if any. */
|
||||
permission?: string;
|
||||
}>;
|
||||
tabs: () => string[];
|
||||
panels: () => string[];
|
||||
hostActions: () => Array<{ id: string; tabType?: string }>;
|
||||
@@ -629,8 +862,19 @@ export interface RenderedPluginApp {
|
||||
slot: (slotId: string) => string[];
|
||||
actions: () => string[];
|
||||
};
|
||||
/** Shell calls made by the plugin's code. */
|
||||
/** Shell calls made by the plugin's code, applyLayout included. */
|
||||
shellCalls: ShellCall[];
|
||||
/**
|
||||
* Tabs the last app.tabs.applyLayout opened, under the shell's own restore
|
||||
* rules: a tab whose host is gone is skipped, a tab no running plugin
|
||||
* registered is kept.
|
||||
*/
|
||||
openedTabs: () => Array<{ type: string; hostId?: number; label: string }>;
|
||||
/**
|
||||
* Renders an opened tab the way the shell would, which for a tab no running
|
||||
* plugin registered is the "needs the plugin" placeholder.
|
||||
*/
|
||||
renderOpenedTab: (index: number) => HTMLElement;
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
|
||||
Reference in new issue
Block a user