mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -66,6 +66,9 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
# The plugin SDK is a workspace package core imports at runtime, so npm ci
|
||||
# has to see it to link it and install its dependencies.
|
||||
COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||
COPY .npmrc ./
|
||||
COPY vendor ./vendor
|
||||
|
||||
@@ -106,6 +109,9 @@ COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
# Bundled first-party plugins (ssh-terminal). Resolved as dist/plugins by
|
||||
# getBundledPluginsDir(); without this the terminal never starts.
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
||||
# Target of node_modules/@termix/plugin-sdk, which core and the plugins import.
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
|
||||
COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled
|
||||
COPY --chown=node:node package.json ./
|
||||
# Schema for Postgres and MySQL. Unused by the default SQLite deployment, which
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
|
||||
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
|
||||
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
|
||||
-- empty query.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -218,6 +218,13 @@
|
||||
"when": 1790135163718,
|
||||
"tag": "0030_sweet_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "5",
|
||||
"when": 1790164291881,
|
||||
"tag": "0031_nebulous_diamondback",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
|
||||
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
|
||||
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
|
||||
-- empty query.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -225,6 +225,13 @@
|
||||
"when": 1790135163099,
|
||||
"tag": "0031_nifty_sentry",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1790164291317,
|
||||
"tag": "0032_nifty_hedge_knight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
|
||||
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
|
||||
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
|
||||
-- empty query.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -197,6 +197,13 @@
|
||||
"when": 1790135162496,
|
||||
"tag": "0027_luxuriant_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "6",
|
||||
"when": 1790164290763,
|
||||
"tag": "0028_public_miracleman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
"dependencies": {
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"service": "workspaces.saved",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
{
|
||||
"key": "api-key",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./services.js";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
startAiService(ctx.http.router());
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
|
||||
interface SavedWorkspaces {
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's saved workspaces, or null when the workspaces plugin is off or
|
||||
* the user may not use it. Optional: the assistant works without it.
|
||||
*/
|
||||
export async function listSavedWorkspaces(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SavedWorkspaces>("workspaces.saved", { userId })
|
||||
.list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
import { listSavedWorkspaces } from "../services.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
@@ -178,16 +178,9 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await createCurrentWorkspaceRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
workspaces: workspaces.map((workspace: any) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isDefault: workspace.isDefault ?? false,
|
||||
})),
|
||||
};
|
||||
const workspaces = await listSavedWorkspaces(context.userId);
|
||||
if (workspaces === null) return { workspaces: [], unavailable: true };
|
||||
return { workspaces };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
|
||||
- Owns its data: core's `user_workspaces` table is renamed to
|
||||
`p_workspaces_workspaces` on first activation, keeping every saved workspace.
|
||||
- New `workspaces.use` permission, given to the admin and user roles. Without
|
||||
it the rail item is hidden and the routes answer 403.
|
||||
- Saved workspaces sync between the desktop app and a server. The per-device
|
||||
"Last Session" does not.
|
||||
- Offers `workspaces.saved` to other plugins, used by the AI assistant.
|
||||
- A tab from a plugin that is off is kept as a placeholder when a workspace is
|
||||
applied, instead of being dropped.
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
Save and restore named tab/split layouts per user.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
Bundled with Termix and enabled by default. This is the reference
|
||||
conversion: it imports only `@termix/plugin-sdk`, and "Converting a feature
|
||||
into a plugin" in packages/plugin-sdk/ARCHITECTURE.md points here for each step.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/backend/ activate(ctx), the table, repository and routes
|
||||
migrations/ one .sql per dialect, generated from src/backend/tables.ts
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
@@ -18,5 +21,6 @@ Bundled with Termix and enabled by default.
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
npx termix-plugin migrations <name> after changing src/backend/tables.ts
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -41,7 +41,13 @@
|
||||
"lastSessionDescription": "Automatically updated with your most recent tab arrangement. Apply it to recover what you had open.",
|
||||
"lastUsed": "Last used {{time}}",
|
||||
"neverUsed": "Never applied",
|
||||
"tabsSkipped": "Workspace applied — {{count}} tab(s) skipped (host no longer exists): {{names}}"
|
||||
"tabsSkipped": "Workspace applied, but {{count}} tab(s) were skipped because their host no longer exists: {{names}}",
|
||||
"timeAgo": {
|
||||
"seconds": "{{count}}s ago",
|
||||
"minutes": "{{count}}m ago",
|
||||
"hours": "{{count}}h ago",
|
||||
"days": "{{count}}d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -51,5 +57,11 @@
|
||||
"onboarding": {
|
||||
"workflow_workspaces": "Workspaces",
|
||||
"workflow_workspaces_desc": "Save a set of open tabs and reopen the whole layout later."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use workspaces",
|
||||
"description": "Save, apply and manage saved tab layouts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "workspaces.saved",
|
||||
"version": "1.0.0",
|
||||
"permission": "workspaces.use"
|
||||
}
|
||||
],
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"contributes": {
|
||||
@@ -24,6 +31,14 @@
|
||||
"titleKey": "nav.workspaces",
|
||||
"icon": "LayoutTemplate"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_workspaces` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`icon` text,
|
||||
`kind` text NOT NULL DEFAULT ('manual'),
|
||||
`is_default` boolean NOT NULL DEFAULT false,
|
||||
`payload` text NOT NULL DEFAULT ('{}'),
|
||||
`sync_id` varchar(255) UNIQUE,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `user_workspaces` TO `p_workspaces_workspaces`;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_workspaces" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"kind" text NOT NULL DEFAULT 'manual',
|
||||
"is_default" boolean NOT NULL DEFAULT false,
|
||||
"payload" text NOT NULL DEFAULT '{}',
|
||||
"sync_id" varchar(255) UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
|
||||
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"workspaces": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"type": "text"
|
||||
},
|
||||
"icon": {
|
||||
"type": "text"
|
||||
},
|
||||
"kind": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "manual"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"payload": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "{}"
|
||||
},
|
||||
"syncId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"type": "timestamp"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_user_workspaces_user_id",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
],
|
||||
"adopts": "user_workspaces"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_workspaces" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"kind" text NOT NULL DEFAULT 'manual',
|
||||
"is_default" integer NOT NULL DEFAULT 0,
|
||||
"payload" text NOT NULL DEFAULT '{}',
|
||||
"sync_id" text UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
|
||||
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
|
||||
@@ -1,11 +1,49 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
|
||||
import { workspaces } from "./tables.js";
|
||||
import { createWorkspaceRepository } from "./repository.js";
|
||||
import { registerWorkspaceRoutes } from "./routes.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("workspaces.saved"). */
|
||||
export interface WorkspacesService {
|
||||
/** The calling user's workspaces, without their layouts. */
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startWorkspacesService(ctx.http.router());
|
||||
const table = await ctx.db.define(workspaces);
|
||||
const repo = createWorkspaceRepository(ctx.db, table);
|
||||
|
||||
registerWorkspaceRoutes(ctx.http.router<Router>(), repo, ctx);
|
||||
|
||||
// The last_session row belongs to one install. Syncing it would leave the
|
||||
// user with two, one from each side.
|
||||
ctx.sync.registerEntity({
|
||||
type: "workspaces",
|
||||
table,
|
||||
order: 200,
|
||||
shouldSync: (row) => row.kind !== "last_session",
|
||||
});
|
||||
|
||||
const service: WorkspacesService = {
|
||||
list: async () => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
const rows = await repo.listByUser(userId);
|
||||
return rows
|
||||
.filter((row) => row.kind === "manual")
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: !!row.isDefault,
|
||||
}));
|
||||
},
|
||||
};
|
||||
ctx.services.provide("workspaces.saved", service);
|
||||
|
||||
ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopWorkspacesService();
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
export interface WorkspaceRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
payload: string;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateInput {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdateInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type WorkspaceRepository = ReturnType<typeof createWorkspaceRepository>;
|
||||
|
||||
/**
|
||||
* Per-user workspace storage.
|
||||
*
|
||||
* Written without RETURNING so it runs the same on all three engines: an
|
||||
* insert is read back by the sync id it was given, an update by its id.
|
||||
*/
|
||||
export function createWorkspaceRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findById(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return (rows[0] as WorkspaceRecord) ?? null;
|
||||
}
|
||||
|
||||
async function findBySyncId(syncId: string): Promise<WorkspaceRecord> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0] as WorkspaceRecord;
|
||||
}
|
||||
|
||||
async function findLastSession(
|
||||
userId: string,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.kind, "last_session")))
|
||||
.limit(1);
|
||||
return (rows[0] as WorkspaceRecord) ?? null;
|
||||
}
|
||||
|
||||
async function insert(
|
||||
userId: string,
|
||||
values: Omit<
|
||||
WorkspaceRecord,
|
||||
"id" | "userId" | "syncId" | "createdAt" | "updatedAt" | "lastUsedAt"
|
||||
>,
|
||||
now: string,
|
||||
): Promise<WorkspaceRecord> {
|
||||
const syncId = randomUUID();
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ ...values, userId, syncId, createdAt: now, updatedAt: now });
|
||||
await db.persist();
|
||||
return findBySyncId(syncId);
|
||||
}
|
||||
|
||||
async function write(
|
||||
userId: string,
|
||||
id: number,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set(values)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return findById(userId, id);
|
||||
}
|
||||
|
||||
/** A manual workspace the user owns, or null. Last Session is not editable. */
|
||||
async function findManual(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await findById(userId, id);
|
||||
return existing && existing.kind === "manual" ? existing : null;
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
findLastSession,
|
||||
|
||||
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle.select().from(table).where(eq(table.userId, userId));
|
||||
},
|
||||
|
||||
async upsertLastSession(
|
||||
userId: string,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const existing = await findLastSession(userId);
|
||||
if (!existing) {
|
||||
return insert(
|
||||
userId,
|
||||
{
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
return write(userId, existing.id, { payload, updatedAt: now });
|
||||
},
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: WorkspaceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
return insert(
|
||||
userId,
|
||||
{
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
},
|
||||
now,
|
||||
);
|
||||
},
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: WorkspaceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await findManual(userId, id);
|
||||
if (!existing) return null;
|
||||
return write(userId, id, {
|
||||
name: input.name ?? existing.name,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
async updateContent(
|
||||
userId: string,
|
||||
id: number,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
return write(userId, id, { payload, updatedAt: now });
|
||||
},
|
||||
|
||||
async setDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.isDefault, true),
|
||||
ne(table.id, id),
|
||||
),
|
||||
);
|
||||
return write(userId, id, { isDefault: true, updatedAt: now });
|
||||
},
|
||||
|
||||
async unsetDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
return write(userId, id, { isDefault: false, updatedAt: now });
|
||||
},
|
||||
|
||||
async touchLastUsed(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await write(userId, id, { lastUsedAt: now });
|
||||
},
|
||||
|
||||
async delete(userId: string, id: number): Promise<boolean> {
|
||||
if (!(await findManual(userId, id))) return false;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import express, { type Request, type Response, type Router } from "express";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { WorkspaceRecord, WorkspaceRepository } from "./repository.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
@@ -46,73 +37,93 @@ function serialize(record: WorkspaceRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
function logError(
|
||||
ctx: PluginContext,
|
||||
message: string,
|
||||
error: unknown,
|
||||
details: { operation: string; workspaceId?: number },
|
||||
): void {
|
||||
const target =
|
||||
details.workspaceId !== undefined ? ` ${details.workspaceId}` : "";
|
||||
ctx.log.error(
|
||||
`${message}${target} (${details.operation})`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* get:
|
||||
* summary: List the current user's saved workspaces
|
||||
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of workspaces.
|
||||
* Mounts the workspace routes on the plugin's router, which core serves at
|
||||
* /plugin-api/workspaces with auth in front. Every route needs workspaces.use,
|
||||
* so an admin can hide the feature per role.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
export function registerWorkspaceRoutes(
|
||||
router: Router,
|
||||
repo: WorkspaceRepository,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces:
|
||||
* get:
|
||||
* summary: List the current user's saved workspaces
|
||||
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of workspaces.
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
|
||||
try {
|
||||
const records =
|
||||
await createCurrentWorkspaceRepository().listByUser(userId);
|
||||
const records = await repo.listByUser(userId);
|
||||
res.json(records.map(serialize));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list workspaces", err, {
|
||||
logError(ctx, "Failed to list workspaces", err, {
|
||||
operation: "workspace_list_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list workspaces" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* post:
|
||||
* summary: Save the current tab arrangement as a new named workspace
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces:
|
||||
* post:
|
||||
* summary: Save the current tab arrangement as a new named workspace
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { name, color, icon, payload } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
@@ -125,7 +136,7 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentWorkspaceRepository().create(userId, {
|
||||
const created = await repo.create(userId, {
|
||||
name: name.trim(),
|
||||
color: isNonEmptyString(color) ? color : null,
|
||||
icon: isNonEmptyString(icon) ? icon : null,
|
||||
@@ -133,77 +144,64 @@ router.post(
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create workspace", err, {
|
||||
logError(ctx, "Failed to create workspace", err, {
|
||||
operation: "workspace_create_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* get:
|
||||
* summary: Fetch the auto-maintained "Last Session" workspace
|
||||
* description: Returns null if the current session has never been auto-saved yet.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The Last Session workspace, or null.
|
||||
*/
|
||||
router.get(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/last-session:
|
||||
* get:
|
||||
* summary: Fetch the auto-maintained "Last Session" workspace
|
||||
* description: Returns null if the current session has never been auto-saved yet.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The Last Session workspace, or null.
|
||||
*/
|
||||
router.get("/last-session", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
|
||||
try {
|
||||
const record =
|
||||
await createCurrentWorkspaceRepository().findLastSession(userId);
|
||||
const record = await repo.findLastSession(userId);
|
||||
res.json(record ? serialize(record) : null);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to fetch last session workspace", err, {
|
||||
logError(ctx, "Failed to fetch last session workspace", err, {
|
||||
operation: "workspace_last_session_get_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* put:
|
||||
* summary: Upsert the auto-maintained "Last Session" workspace
|
||||
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Last Session workspace saved.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.put(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/last-session:
|
||||
* put:
|
||||
* summary: Upsert the auto-maintained "Last Session" workspace
|
||||
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Last Session workspace saved.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.put("/last-session", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { payload } = req.body ?? {};
|
||||
|
||||
if (!isValidPayload(payload)) {
|
||||
@@ -213,49 +211,43 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await createCurrentWorkspaceRepository().upsertLastSession(
|
||||
const saved = await repo.upsertLastSession(
|
||||
userId,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
res.json(serialize(saved));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to save last session workspace", err, {
|
||||
logError(ctx, "Failed to save last session workspace", err, {
|
||||
operation: "workspace_last_session_save_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* patch:
|
||||
* summary: Rename or recolor a workspace
|
||||
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace updated.
|
||||
* 400:
|
||||
* description: Invalid request, or target is the Last Session workspace.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}:
|
||||
* patch:
|
||||
* summary: Rename or recolor a workspace
|
||||
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace updated.
|
||||
* 400:
|
||||
* description: Invalid request, or target is the Last Session workspace.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.patch("/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
@@ -267,68 +259,58 @@ router.patch(
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().update(
|
||||
userId,
|
||||
id,
|
||||
{
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
color,
|
||||
icon,
|
||||
},
|
||||
);
|
||||
const updated = await repo.update(userId, id, {
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace", err, {
|
||||
logError(ctx, "Failed to update workspace", err, {
|
||||
operation: "workspace_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/content:
|
||||
* put:
|
||||
* summary: Overwrite a workspace's saved tab arrangement with a new payload
|
||||
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace content updated.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/content",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/content:
|
||||
* put:
|
||||
* summary: Overwrite a workspace's saved tab arrangement with a new payload
|
||||
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace content updated.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.put("/:id/content", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
@@ -342,7 +324,7 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().updateContent(
|
||||
const updated = await repo.updateContent(
|
||||
userId,
|
||||
id,
|
||||
JSON.stringify(payload),
|
||||
@@ -353,50 +335,44 @@ router.put(
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace content", err, {
|
||||
logError(ctx, "Failed to update workspace content", err, {
|
||||
operation: "workspace_content_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace content" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a workspace's content and color/icon under a new name
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New workspace created from the duplicate.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/duplicate",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a workspace's content and color/icon under a new name
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New workspace created from the duplicate.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/duplicate", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
const { name } = req.body ?? {};
|
||||
if (id === null) {
|
||||
@@ -407,13 +383,12 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const source = await repository.findById(userId, id);
|
||||
const source = await repo.findById(userId, id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
const created = await repository.create(userId, {
|
||||
const created = await repo.create(userId, {
|
||||
name: name.trim(),
|
||||
color: source.color,
|
||||
icon: source.icon,
|
||||
@@ -421,224 +396,181 @@ router.post(
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to duplicate workspace", err, {
|
||||
logError(ctx, "Failed to duplicate workspace", err, {
|
||||
operation: "workspace_duplicate_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to duplicate workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/set-default:
|
||||
* post:
|
||||
* summary: Mark a workspace as the restore-on-login default
|
||||
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace set as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/set-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/set-default:
|
||||
* post:
|
||||
* summary: Mark a workspace as the restore-on-login default
|
||||
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace set as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/set-default", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().setDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const updated = await repo.setDefault(userId, id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to set default workspace", err, {
|
||||
logError(ctx, "Failed to set default workspace", err, {
|
||||
operation: "workspace_set_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/unset-default:
|
||||
* post:
|
||||
* summary: Remove a workspace as the restore-on-login default
|
||||
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace unset as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/unset-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/unset-default:
|
||||
* post:
|
||||
* summary: Remove a workspace as the restore-on-login default
|
||||
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace unset as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/unset-default", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().unsetDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const updated = await repo.unsetDefault(userId, id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to unset default workspace", err, {
|
||||
logError(ctx, "Failed to unset default workspace", err, {
|
||||
operation: "workspace_unset_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unset default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/apply:
|
||||
* post:
|
||||
* summary: Fetch a workspace to apply and mark it as just used
|
||||
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The workspace to apply.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/apply",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/apply:
|
||||
* post:
|
||||
* summary: Fetch a workspace to apply and mark it as just used
|
||||
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The workspace to apply.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const record = await repository.findById(userId, id);
|
||||
const record = await repo.findById(userId, id);
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
await repository.touchLastUsed(userId, id);
|
||||
await repo.touchLastUsed(userId, id);
|
||||
res.json(serialize(record));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to apply workspace", err, {
|
||||
logError(ctx, "Failed to apply workspace", err, {
|
||||
operation: "workspace_apply_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* delete:
|
||||
* summary: Delete a workspace
|
||||
* description: Rejects the Last Session workspace, which is not user-deletable.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace deleted.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}:
|
||||
* delete:
|
||||
* summary: Delete a workspace
|
||||
* description: Rejects the Last Session workspace, which is not user-deletable.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace deleted.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentWorkspaceRepository().delete(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const deleted = await repo.delete(userId, id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to delete workspace", err, {
|
||||
logError(ctx, "Failed to delete workspace", err, {
|
||||
operation: "workspace_delete_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function startWorkspacesService(mountOn: Router): void {
|
||||
mountOn.use(router);
|
||||
}
|
||||
|
||||
export function stopWorkspacesService(): void {
|
||||
// Unmounted by the runtime when the plugin deactivates.
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Saved tab arrangements, one row per workspace plus one "last_session" row
|
||||
* per user that the frontend keeps current.
|
||||
*
|
||||
* Adopted from core's user_workspaces, so the column and index names are the
|
||||
* legacy ones: the rename carries the existing rows and index across.
|
||||
*/
|
||||
export const workspaces = adoptLegacyTable(
|
||||
"user_workspaces",
|
||||
defineTable(
|
||||
"workspaces",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
color: text(),
|
||||
icon: text(),
|
||||
// "manual" | "last_session"
|
||||
kind: text().notNull().default("manual"),
|
||||
isDefault: boolean().notNull().default(false),
|
||||
// JSON: the shell layout from app.tabs.getLayout
|
||||
payload: text().notNull().default("{}"),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
lastUsedAt: timestamp(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_user_workspaces_user_id", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [workspaces];
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type ShellLayout,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutTemplate,
|
||||
@@ -12,40 +15,34 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import { FOLDER_COLORS } from "@/lib/theme";
|
||||
import {
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceContent,
|
||||
deleteWorkspace,
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
unsetDefaultWorkspace,
|
||||
} from "./workspaces-api";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
FOLDER_COLORS,
|
||||
Input,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { createWorkspacesApi, type WorkspacesApi } from "./workspaces-api";
|
||||
import { errorMessage, type Workspace } from "./types";
|
||||
|
||||
function timeAgo(isoStr: string): string {
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function timeAgo(isoStr: string, t: Translate): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const prefix = "newUi.sidebar.workspaces.timeAgo";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 60) return t(`${prefix}.seconds`, { count: sec });
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
if (min < 60) return t(`${prefix}.minutes`, { count: min });
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
if (hr < 24) return t(`${prefix}.hours`, { count: hr });
|
||||
return t(`${prefix}.days`, { count: Math.floor(hr / 24) });
|
||||
}
|
||||
|
||||
function WorkspaceSaveDialog({
|
||||
@@ -153,10 +150,12 @@ function WorkspaceSaveDialog({
|
||||
}
|
||||
|
||||
function WorkspaceRenameDialog({
|
||||
api,
|
||||
workspace,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
api: WorkspacesApi;
|
||||
workspace: Workspace | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
@@ -180,12 +179,12 @@ function WorkspaceRenameDialog({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await renameWorkspace(workspace.id, { name: name.trim(), color });
|
||||
await api.rename(workspace.id, { name: name.trim(), color });
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -309,7 +308,7 @@ function WorkspaceRow({
|
||||
? t("newUi.sidebar.workspaces.lastSessionDescription")
|
||||
: workspace.lastUsedAt
|
||||
? t("newUi.sidebar.workspaces.lastUsed", {
|
||||
time: timeAgo(workspace.lastUsedAt),
|
||||
time: timeAgo(workspace.lastUsedAt, t),
|
||||
})
|
||||
: t("newUi.sidebar.workspaces.neverUsed")}
|
||||
</span>
|
||||
@@ -390,10 +389,12 @@ export function WorkspacesPanel({
|
||||
onApplyWorkspace,
|
||||
}: {
|
||||
active?: boolean;
|
||||
currentPayload: () => WorkspacePayload;
|
||||
currentPayload: () => ShellLayout | null;
|
||||
onApplyWorkspace: (workspace: Workspace) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWorkspacesApi(pluginApi), [pluginApi]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
@@ -404,13 +405,13 @@ export function WorkspacesPanel({
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const data = await listWorkspaces();
|
||||
const data = await api.list();
|
||||
setWorkspaces(data);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.loadFailed"));
|
||||
}
|
||||
}, [t]);
|
||||
}, [api, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || hasLoadedRef.current) return;
|
||||
@@ -421,11 +422,13 @@ export function WorkspacesPanel({
|
||||
|
||||
async function handleSaveNew(name: string, color: string) {
|
||||
try {
|
||||
await createWorkspace({ name, color, payload: currentPayload() });
|
||||
const payload = currentPayload();
|
||||
if (!payload) return;
|
||||
await api.create({ name, color, payload });
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceCreated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.saveFailed"));
|
||||
throw error;
|
||||
}
|
||||
@@ -433,18 +436,20 @@ export function WorkspacesPanel({
|
||||
|
||||
async function handleUpdateWithCurrent(workspace: Workspace) {
|
||||
try {
|
||||
await updateWorkspaceContent(workspace.id, currentPayload());
|
||||
const payload = currentPayload();
|
||||
if (!payload) return;
|
||||
await api.updateContent(workspace.id, payload);
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(workspace: Workspace) {
|
||||
try {
|
||||
await duplicateWorkspace(
|
||||
await api.duplicate(
|
||||
workspace.id,
|
||||
t("newUi.sidebar.workspaces.duplicateNameSuffix", {
|
||||
name: workspace.name,
|
||||
@@ -453,7 +458,7 @@ export function WorkspacesPanel({
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceDuplicated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.duplicateFailed"));
|
||||
}
|
||||
}
|
||||
@@ -461,13 +466,13 @@ export function WorkspacesPanel({
|
||||
async function handleToggleDefault(workspace: Workspace) {
|
||||
try {
|
||||
if (workspace.isDefault) {
|
||||
await unsetDefaultWorkspace(workspace.id);
|
||||
await api.unsetDefault(workspace.id);
|
||||
} else {
|
||||
await setDefaultWorkspace(workspace.id);
|
||||
await api.setDefault(workspace.id);
|
||||
}
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
}
|
||||
}
|
||||
@@ -475,12 +480,12 @@ export function WorkspacesPanel({
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteWorkspace(deleteTarget.id);
|
||||
await api.remove(deleteTarget.id);
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceDeleted"));
|
||||
setDeleteTarget(null);
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.deleteFailed"));
|
||||
}
|
||||
}
|
||||
@@ -584,6 +589,7 @@ export function WorkspacesPanel({
|
||||
/>
|
||||
|
||||
<WorkspaceRenameDialog
|
||||
api={api}
|
||||
workspace={renameTarget}
|
||||
onClose={() => setRenameTarget(null)}
|
||||
onSaved={loadWorkspaces}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
import { WorkspacesPanel } from "./WorkspacesPanel";
|
||||
import {
|
||||
applyWorkspaceServer,
|
||||
listWorkspaces,
|
||||
saveLastSessionWorkspace,
|
||||
} from "./workspaces-api";
|
||||
import { createWorkspacesApi } from "./workspaces-api";
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
const LAST_SESSION_DELAY_MS = 2000;
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
const api = createWorkspacesApi(app.api);
|
||||
|
||||
const applyWorkspace = async (workspace: Workspace) => {
|
||||
const { skipped } = await app.tabs.applyLayout(
|
||||
workspace.payload as unknown as Parameters<
|
||||
typeof app.tabs.applyLayout
|
||||
>[0],
|
||||
{ name: workspace.name },
|
||||
);
|
||||
const { skipped } = await app.tabs.applyLayout(workspace.payload, {
|
||||
name: workspace.name,
|
||||
});
|
||||
if (skipped.length > 0) {
|
||||
toast.warning(
|
||||
i18next.t("workspaces:newUi.sidebar.workspaces.tabsSkipped", {
|
||||
app.t("newUi.sidebar.workspaces.tabsSkipped", {
|
||||
count: skipped.length,
|
||||
names: skipped.join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
applyWorkspaceServer(workspace.id).catch(() => {});
|
||||
api.apply(workspace.id).catch(() => {});
|
||||
};
|
||||
|
||||
function Panel({ active }: PanelProps) {
|
||||
@@ -36,9 +30,7 @@ export function activate(app: TermixApp): void {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<WorkspacesPanel
|
||||
active={active}
|
||||
currentPayload={() =>
|
||||
app.tabs.getLayout() as unknown as WorkspacePayload
|
||||
}
|
||||
currentPayload={() => app.tabs.getLayout()}
|
||||
onApplyWorkspace={(workspace) => void applyWorkspace(workspace)}
|
||||
/>
|
||||
</div>
|
||||
@@ -50,22 +42,32 @@ export function activate(app: TermixApp): void {
|
||||
icon: LayoutTemplate,
|
||||
titleKey: "nav.workspaces",
|
||||
after: "split-screen",
|
||||
permission: "use",
|
||||
});
|
||||
app.registerPanel("workspaces", Panel);
|
||||
|
||||
// Without workspaces.use the restore and the autosave below would only
|
||||
// collect 403s. The lookup is cached, so asking each time is cheap and a
|
||||
// revoke takes effect.
|
||||
const allowed = () => app.hasPermission("use");
|
||||
|
||||
// After login, a default workspace applies unless the session already
|
||||
// brought tabs back, which is the more precise restore.
|
||||
app.tabs.onReady(() => {
|
||||
const layout = app.tabs.getLayout() as unknown as WorkspacePayload | null;
|
||||
if (layout && layout.tabs.length > 0) return;
|
||||
listWorkspaces()
|
||||
.then((workspaces) => {
|
||||
const layout = app.tabs.getLayout() as { tabs?: unknown[] } | null;
|
||||
if (layout && (layout.tabs?.length ?? 0) > 0) return;
|
||||
void allowed().then(async (ok) => {
|
||||
if (!ok) return;
|
||||
try {
|
||||
const workspaces = await api.list();
|
||||
const preferred = workspaces.find(
|
||||
(workspace) => workspace.kind === "manual" && workspace.isDefault,
|
||||
);
|
||||
if (preferred) void applyWorkspace(preferred);
|
||||
})
|
||||
.catch(() => {});
|
||||
if (preferred) await applyWorkspace(preferred);
|
||||
} catch {
|
||||
// A failed restore leaves the empty session as it is.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keeps an implicit "last session" snapshot current, so an arrangement can
|
||||
@@ -75,11 +77,10 @@ export function activate(app: TermixApp): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const layout = app.tabs.getLayout();
|
||||
if (layout) {
|
||||
saveLastSessionWorkspace(layout as unknown as WorkspacePayload).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
if (!layout) return;
|
||||
void allowed().then((ok) => {
|
||||
if (ok) api.saveLastSession(layout).catch(() => {});
|
||||
});
|
||||
}, LAST_SESSION_DELAY_MS);
|
||||
});
|
||||
app.onDispose(() => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ShellLayout } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
/** A saved workspace as the routes return it. */
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
/** Whatever app.tabs.getLayout returned when it was saved. */
|
||||
payload: ShellLayout & { tabs?: unknown[] };
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
tabCount: number;
|
||||
}
|
||||
|
||||
/** The server's error text when it sent one, else the error's own message. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
if (typeof data?.error === "string") return data.error;
|
||||
return "";
|
||||
}
|
||||
@@ -1,132 +1,38 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
import type { PluginApiClient, ShellLayout } from "@termix/plugin-sdk/frontend";
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/workspaces");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch workspaces");
|
||||
}
|
||||
/**
|
||||
* The workspace routes, through the plugin's own client. Paths are relative
|
||||
* to /plugin-api/workspaces/, which the client already points at.
|
||||
*/
|
||||
export function createWorkspacesApi(api: PluginApiClient) {
|
||||
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
|
||||
|
||||
return {
|
||||
list: () => data(api.get<Workspace[]>("/")),
|
||||
create: (body: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: ShellLayout;
|
||||
}) => data(api.post<Workspace>("/", body)),
|
||||
rename: (
|
||||
id: number,
|
||||
body: { name?: string; color?: string | null; icon?: string | null },
|
||||
) => data(api.patch<Workspace>(`/${id}`, body)),
|
||||
updateContent: (id: number, payload: ShellLayout) =>
|
||||
data(api.put<Workspace>(`/${id}/content`, { payload })),
|
||||
remove: (id: number) => data(api.delete<{ success: boolean }>(`/${id}`)),
|
||||
duplicate: (id: number, name: string) =>
|
||||
data(api.post<Workspace>(`/${id}/duplicate`, { name })),
|
||||
setDefault: (id: number) => data(api.post<Workspace>(`/${id}/set-default`)),
|
||||
unsetDefault: (id: number) =>
|
||||
data(api.post<Workspace>(`/${id}/unset-default`)),
|
||||
apply: (id: number) => data(api.post<Workspace>(`/${id}/apply`)),
|
||||
getLastSession: () => data(api.get<Workspace | null>("/last-session")),
|
||||
saveLastSession: (payload: ShellLayout) =>
|
||||
data(api.put<Workspace>("/last-session", { payload })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkspace(data: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: WorkspacePayload;
|
||||
}): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post("/plugin-api/workspaces", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameWorkspace(
|
||||
id: number,
|
||||
data: { name?: string; color?: string | null; icon?: string | null },
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkspaceContent(
|
||||
id: number,
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace content");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
id: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/plugin-api/workspaces/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateWorkspace(
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/duplicate`,
|
||||
{
|
||||
name,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "duplicate workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/set-default`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "set default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/unset-default`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unset default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/workspaces/last-session");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch last session workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLastSessionWorkspace(
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put("/plugin-api/workspaces/last-session", {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save last session workspace");
|
||||
}
|
||||
}
|
||||
export type WorkspacesApi = ReturnType<typeof createWorkspacesApi>;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("workspaces activate", () => {
|
||||
it("registers a sync entity that leaves the per-install last session out", async () => {
|
||||
server = await startServer();
|
||||
const [entity] = server.mock.syncEntities;
|
||||
expect(entity.type).toBe("workspaces");
|
||||
expect(entity.shouldSync?.({ kind: "manual" })).toBe(true);
|
||||
expect(entity.shouldSync?.({ kind: "last_session" })).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the user's manual workspaces as a service", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "Prod", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
await server.request("PUT", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
|
||||
const service = server.mock.services.get("workspaces.saved") as {
|
||||
list: () => Promise<unknown>;
|
||||
};
|
||||
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
{ id: expect.any(Number), name: "Prod", isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed without db:own", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: ["network:serve", "ui:surface"],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: ["db:own", "ui:surface"],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: { permissions?: string[]; users?: string[] } = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["workspaces.use"],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const layout = (
|
||||
tabs: unknown[] = [{ slotId: "a", type: "terminal" }],
|
||||
) => ({
|
||||
version: 1,
|
||||
tabs,
|
||||
activeSlotId: null,
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// user_workspaces as core's SQLite bootstrap created it before 2.9.0, plus the
|
||||
// index performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE user_workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_user_workspaces_user_id ON user_workspaces (user_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting user_workspaces", () => {
|
||||
it("keeps every existing row when the legacy table is there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_workspaces (user_id, name, kind, is_default, payload, sync_id, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"user-1",
|
||||
"Prod",
|
||||
"manual",
|
||||
1,
|
||||
'{"version":1,"tabs":[{"type":"terminal"}]}',
|
||||
"sync-a",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_workspaces (user_id, name, kind, payload, sync_id) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-2", "Last Session", "last_session", "{}", "sync-b");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_user_workspaces"]);
|
||||
expect(tableExists("user_workspaces")).toBe(false);
|
||||
expect(tableExists("p_workspaces_workspaces")).toBe(true);
|
||||
|
||||
const rows = db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, name, kind, is_default, payload, sync_id, last_used_at FROM p_workspaces_workspaces ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
name: "Prod",
|
||||
kind: "manual",
|
||||
is_default: 1,
|
||||
payload: '{"version":1,"tabs":[{"type":"terminal"}]}',
|
||||
sync_id: "sync-a",
|
||||
last_used_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
name: "Last Session",
|
||||
kind: "last_session",
|
||||
is_default: 0,
|
||||
payload: "{}",
|
||||
sync_id: "sync-b",
|
||||
last_used_at: null,
|
||||
},
|
||||
]);
|
||||
expect(indexOn("p_workspaces_workspaces")).toEqual([
|
||||
"idx_user_workspaces_user_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("user_workspaces")).toBe(false);
|
||||
expect(tableExists("p_workspaces_workspaces")).toBe(true);
|
||||
expect(indexOn("p_workspaces_workspaces")).toEqual([
|
||||
"idx_user_workspaces_user_id",
|
||||
]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_workspaces_workspaces").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes a user's workspaces when the user is deleted", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
},
|
||||
});
|
||||
const insert = db.sqlite.prepare(
|
||||
"INSERT INTO p_workspaces_workspaces (user_id, name) VALUES (?, ?)",
|
||||
);
|
||||
insert.run("user-1", "Mine");
|
||||
insert.run("user-2", "Theirs");
|
||||
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
const names = (
|
||||
db.sqlite.prepare("SELECT name FROM p_workspaces_workspaces").all() as {
|
||||
name: string;
|
||||
}[]
|
||||
).map((row) => row.name);
|
||||
expect(names).toEqual(["Theirs"]);
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
|
||||
import { WorkspaceRepository } from "../../../../../src/backend/database/repositories/workspace-repository.js";
|
||||
|
||||
describe("WorkspaceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<WorkspaceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new WorkspaceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists manual workspaces scoped to the owning user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", { name: "Prod Debugging", payload: "{}" });
|
||||
await repo.create("user-2", { name: "Other user's", payload: "{}" });
|
||||
|
||||
const list = await repo.listByUser("user-1");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "Prod Debugging",
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
});
|
||||
expect(list[0].syncId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("finds a workspace by id scoped to the owner", async () => {
|
||||
const repo = await createRepository();
|
||||
const created = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
|
||||
expect(await repo.findById("user-1", created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
});
|
||||
expect(await repo.findById("user-2", created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("upsertLastSession creates then updates a single row, never a second one", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}');
|
||||
expect(first.kind).toBe("last_session");
|
||||
|
||||
const second = await repo.upsertLastSession(
|
||||
"user-1",
|
||||
'{"tabs":[{"slotId":"a"}]}',
|
||||
);
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.payload).toBe('{"tabs":[{"slotId":"a"}]}');
|
||||
|
||||
const all = await repo.listByUser("user-1");
|
||||
expect(all.filter((w) => w.kind === "last_session")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("update renames/recolors a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Old Name",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.update("user-1", manual.id, {
|
||||
name: "New Name",
|
||||
color: "#fff",
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "New Name", color: "#fff" });
|
||||
|
||||
const rejected = await repo.update("user-1", lastSession.id, {
|
||||
name: "Should not work",
|
||||
});
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("updateContent overwrites payload for a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.updateContent(
|
||||
"user-1",
|
||||
manual.id,
|
||||
'{"tabs":[1]}',
|
||||
);
|
||||
expect(updated?.payload).toBe('{"tabs":[1]}');
|
||||
|
||||
const rejected = await repo.updateContent(
|
||||
"user-1",
|
||||
lastSession.id,
|
||||
'{"tabs":[2]}',
|
||||
);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("setDefault clears any prior default and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.setDefault("user-1", b.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
|
||||
|
||||
const rejected = await repo.setDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("unsetDefault clears isDefault on a manual workspace and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.unsetDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
|
||||
const rejected = await repo.unsetDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("touchLastUsed sets lastUsedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
const workspace = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
expect(workspace.lastUsedAt).toBeNull();
|
||||
|
||||
await repo.touchLastUsed(
|
||||
"user-1",
|
||||
workspace.id,
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
expect((await repo.findById("user-1", workspace.id))?.lastUsedAt).toBe(
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await expect(repo.delete("user-1", lastSession.id)).resolves.toBe(false);
|
||||
await expect(repo.delete("user-1", manual.id)).resolves.toBe(true);
|
||||
expect(await repo.findById("user-1", manual.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers writes on create/update/delete", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const created = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
await repo.update("user-1", created.id, { name: "B" });
|
||||
await repo.delete("user-1", created.id);
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deleteByUserId removes every workspace owned by the user", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
await repo.create("user-2", { name: "C", payload: "{}" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
expect(await repo.listByUser("user-1")).toHaveLength(0);
|
||||
expect(await repo.listByUser("user-2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { workspaces } from "../../src/backend/tables.js";
|
||||
import {
|
||||
createWorkspaceRepository,
|
||||
type WorkspaceRepository,
|
||||
} from "../../src/backend/repository.js";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb;
|
||||
let repo: WorkspaceRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) =>
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')"),
|
||||
});
|
||||
repo = createWorkspaceRepository(
|
||||
db.database,
|
||||
await db.database.define(workspaces),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
describe("workspace repository", () => {
|
||||
it("reads a new row back by its own sync id", async () => {
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
expect(a.name).toBe("A");
|
||||
expect(b.name).toBe("B");
|
||||
expect(a.syncId).not.toBe(b.syncId);
|
||||
expect(a.createdAt).toBe(a.updatedAt);
|
||||
});
|
||||
|
||||
it("returns the updated last session rather than a new one", async () => {
|
||||
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}', "t1");
|
||||
const second = await repo.upsertLastSession("user-1", '{"tabs":[1]}', "t2");
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.payload).toBe('{"tabs":[1]}');
|
||||
expect(second.updatedAt).toBe("t2");
|
||||
});
|
||||
|
||||
it("switches the default without touching another user's", async () => {
|
||||
const theirs = await repo.create("user-2", { name: "T", payload: "{}" });
|
||||
await repo.setDefault("user-2", theirs.id);
|
||||
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
await repo.setDefault("user-1", a.id);
|
||||
await repo.setDefault("user-1", b.id);
|
||||
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
|
||||
expect((await repo.findById("user-2", theirs.id))?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to edit or delete the last session", async () => {
|
||||
const last = await repo.upsertLastSession("user-1", "{}");
|
||||
expect(await repo.update("user-1", last.id, { name: "x" })).toBeNull();
|
||||
expect(await repo.setDefault("user-1", last.id)).toBeNull();
|
||||
expect(await repo.delete("user-1", last.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,518 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { layout, startServer, type TestServer } from "./helpers";
|
||||
|
||||
type WorkspaceRow = {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: "manual" | "last_session";
|
||||
isDefault: boolean;
|
||||
payload: string;
|
||||
syncId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
let server: TestServer;
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
workspaces: new Map<number, WorkspaceRow>(),
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function findByIdForUser(userId: string, id: number): WorkspaceRow | null {
|
||||
const row = state.workspaces.get(id);
|
||||
return row && row.userId === userId ? row : null;
|
||||
}
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentWorkspaceRepository: () => ({
|
||||
listByUser: async (userId: string) =>
|
||||
[...state.workspaces.values()].filter((w) => w.userId === userId),
|
||||
findById: async (userId: string, id: number) => findByIdForUser(userId, id),
|
||||
findLastSession: async (userId: string) =>
|
||||
[...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
) ?? null,
|
||||
upsertLastSession: async (userId: string, payload: string) => {
|
||||
const existing = [...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
);
|
||||
if (existing) {
|
||||
existing.payload = payload;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return existing;
|
||||
}
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
create: async (
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
},
|
||||
) => {
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
update: async (
|
||||
userId: string,
|
||||
id: number,
|
||||
input: { name?: string; color?: string | null; icon?: string | null },
|
||||
) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
if (input.name !== undefined) row.name = input.name;
|
||||
if (input.color !== undefined) row.color = input.color;
|
||||
if (input.icon !== undefined) row.icon = input.icon;
|
||||
return row;
|
||||
},
|
||||
updateContent: async (userId: string, id: number, payload: string) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.payload = payload;
|
||||
return row;
|
||||
},
|
||||
setDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
for (const w of state.workspaces.values()) {
|
||||
if (w.userId === userId && w.kind === "manual") w.isDefault = false;
|
||||
}
|
||||
row.isDefault = true;
|
||||
return row;
|
||||
},
|
||||
unsetDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.isDefault = false;
|
||||
return row;
|
||||
},
|
||||
touchLastUsed: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (row) row.lastUsedAt = new Date().toISOString();
|
||||
},
|
||||
delete: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return false;
|
||||
state.workspaces.delete(id);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/backend/database/routes/workspace-dispatch.js",
|
||||
() => ({
|
||||
registerWorkspacesRouter: vi.fn(),
|
||||
unregisterWorkspacesRouter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { router } = await import("../../src/backend/routes.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const handler = findLayer(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
await handler(req, res);
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.workspaces = new Map();
|
||||
state.nextId = 1;
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("returns the list with a computed tabCount", async () => {
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Test A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }, { slotId: "b" }] },
|
||||
},
|
||||
});
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toHaveLength(1);
|
||||
async function create(name = "Prod", user = "user-1") {
|
||||
const res = await server.request("POST", "/", {
|
||||
user,
|
||||
body: { name, color: "#ef4444", payload: layout() },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
describe("workspace routes", () => {
|
||||
it("creates and lists workspaces with a parsed payload and tab count", async () => {
|
||||
const created = await create();
|
||||
expect(created).toMatchObject({
|
||||
name: "Prod",
|
||||
color: "#ef4444",
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
tabCount: 1,
|
||||
});
|
||||
expect(created.payload.tabs).toHaveLength(1);
|
||||
expect(created.syncId).toEqual(expect.any(String));
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body).toHaveLength(1);
|
||||
expect(list.body[0].id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("rejects a missing name or payload", async () => {
|
||||
expect(
|
||||
(res.jsonBody as unknown as { tabCount: number }[])[0].tabCount,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("400s when name is missing", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("400s when payload has no tabs array", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1 } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("200s and creates a manual workspace", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "Test A", kind: "manual" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /:id", () => {
|
||||
it("rejects renaming the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "Nope" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("renames a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "Old", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "New" });
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: "999" },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /:id/content", () => {
|
||||
it("updates payload for a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "x" }] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
});
|
||||
|
||||
it("404s on wrong owner", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /:id", () => {
|
||||
it("rejects deleting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("delete", "/:id", { params: { id: "999" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("deletes a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/duplicate", () => {
|
||||
it("produces a second independent row", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
},
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const dup = await invoke("post", "/:id/duplicate", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "A (copy)" },
|
||||
});
|
||||
expect(dup.statusCode).toBe(200);
|
||||
expect(dup.jsonBody).toMatchObject({ name: "A (copy)", tabCount: 1 });
|
||||
|
||||
const updateOriginal = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(updateOriginal.jsonBody).toMatchObject({ tabCount: 0 });
|
||||
|
||||
const dupId = (dup.jsonBody as unknown as { id: number }).id;
|
||||
const refetched = await invoke("get", "/");
|
||||
const dupRow = (
|
||||
refetched.jsonBody as unknown as { id: number; tabCount: number }[]
|
||||
).find((w) => w.id === dupId);
|
||||
expect(dupRow?.tabCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/set-default", () => {
|
||||
it("clears a previous default and sets the new one", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const b = await invoke("post", "/", {
|
||||
body: { name: "B", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
const bId = (b.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const second = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(bId) },
|
||||
});
|
||||
expect(second.jsonBody).toMatchObject({ isDefault: true });
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const aRow = (
|
||||
list.jsonBody as unknown as { id: number; isDefault: boolean }[]
|
||||
).find((w) => w.id === aId);
|
||||
expect(aRow?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent when re-called on an already-default row", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const again = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(again.statusCode).toBe(200);
|
||||
expect(again.jsonBody).toMatchObject({ isDefault: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/unset-default", () => {
|
||||
it("clears isDefault on a manual workspace", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ isDefault: false });
|
||||
});
|
||||
|
||||
it("rejects unsetting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/apply", () => {
|
||||
it("touches lastUsedAt and returns the parsed payload", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/apply", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
});
|
||||
(await server.request("POST", "/", { body: { payload: layout() } }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(res.jsonBody as unknown as { lastUsedAt: string | null }).lastUsedAt,
|
||||
).toBeTruthy();
|
||||
(await server.request("POST", "/", { body: { name: "x", payload: {} } }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps one last session row per user and never lists it as editable", async () => {
|
||||
expect((await server.request("GET", "/last-session")).body).toBeNull();
|
||||
|
||||
await server.request("PUT", "/last-session", {
|
||||
body: { payload: layout() },
|
||||
});
|
||||
const second = await server.request("PUT", "/last-session", {
|
||||
body: { payload: layout([]) },
|
||||
});
|
||||
expect(second.body.tabCount).toBe(0);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
expect(
|
||||
list.filter(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) =>
|
||||
w.kind === "last_session",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const id = second.body.id;
|
||||
expect(
|
||||
(await server.request("PATCH", `/${id}`, { body: { name: "x" } })).status,
|
||||
).toBe(404);
|
||||
expect((await server.request("DELETE", `/${id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("renames, replaces content and duplicates", async () => {
|
||||
const created = await create();
|
||||
|
||||
const renamed = await server.request("PATCH", `/${created.id}`, {
|
||||
body: { name: " Staging ", color: "#22c55e" },
|
||||
});
|
||||
expect(renamed.body).toMatchObject({ name: "Staging", color: "#22c55e" });
|
||||
|
||||
const replaced = await server.request("PUT", `/${created.id}/content`, {
|
||||
body: { payload: layout([]) },
|
||||
});
|
||||
expect(replaced.body.tabCount).toBe(0);
|
||||
|
||||
const copy = await server.request("POST", `/${created.id}/duplicate`, {
|
||||
body: { name: "Staging (copy)" },
|
||||
});
|
||||
expect(copy.body).toMatchObject({
|
||||
name: "Staging (copy)",
|
||||
color: "#22c55e",
|
||||
tabCount: 0,
|
||||
});
|
||||
expect(copy.body.id).not.toBe(created.id);
|
||||
});
|
||||
|
||||
it("keeps a single default", async () => {
|
||||
const a = await create("A");
|
||||
const b = await create("B");
|
||||
|
||||
await server.request("POST", `/${a.id}/set-default`);
|
||||
await server.request("POST", `/${b.id}/set-default`);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
const defaults = list.filter(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) => w.isDefault,
|
||||
);
|
||||
expect(
|
||||
defaults.map(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) => w.id,
|
||||
),
|
||||
).toEqual([b.id]);
|
||||
|
||||
const unset = await server.request("POST", `/${b.id}/unset-default`);
|
||||
expect(unset.body.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a workspace as used when it is applied", async () => {
|
||||
const created = await create();
|
||||
expect(created.lastUsedAt).toBeNull();
|
||||
|
||||
const applied = await server.request("POST", `/${created.id}/apply`);
|
||||
expect(applied.status).toBe(200);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
expect(list[0].lastUsedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("deletes a workspace", async () => {
|
||||
const created = await create();
|
||||
expect((await server.request("DELETE", `/${created.id}`)).body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect((await server.request("GET", "/")).body).toEqual([]);
|
||||
expect((await server.request("DELETE", `/${created.id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects ids that are not numbers", async () => {
|
||||
expect((await server.request("DELETE", "/abc")).status).toBe(400);
|
||||
expect((await server.request("POST", "/abc/apply")).status).toBe(400);
|
||||
});
|
||||
|
||||
it("never shows or changes another user's workspaces", async () => {
|
||||
const mine = await create("Mine", "user-1");
|
||||
|
||||
expect((await server.request("GET", "/", { user: "user-2" })).body).toEqual(
|
||||
[],
|
||||
);
|
||||
for (const [method, path] of [
|
||||
["PATCH", `/${mine.id}`],
|
||||
["DELETE", `/${mine.id}`],
|
||||
["POST", `/${mine.id}/apply`],
|
||||
["POST", `/${mine.id}/set-default`],
|
||||
["POST", `/${mine.id}/duplicate`],
|
||||
] as const) {
|
||||
const res = await server.request(method, path, {
|
||||
user: "user-2",
|
||||
body: { name: "stolen" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes the database after every write", async () => {
|
||||
const before = server.db.persisted;
|
||||
const created = await create();
|
||||
await server.request("POST", `/${created.id}/set-default`);
|
||||
await server.request("DELETE", `/${created.id}`);
|
||||
expect(server.db.persisted).toBeGreaterThanOrEqual(before + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /last-session and GET /last-session", () => {
|
||||
it("returns null before any save", async () => {
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toBeNull();
|
||||
});
|
||||
describe("workspaces.use", () => {
|
||||
it("refuses every route without it", async () => {
|
||||
await server.close();
|
||||
server = await startServer({ permissions: [] });
|
||||
|
||||
it("upserts idempotently - two calls produce one row", async () => {
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const lastSessionRows = (
|
||||
list.jsonBody as unknown as { kind: string }[]
|
||||
).filter((w) => w.kind === "last_session");
|
||||
expect(lastSessionRows).toHaveLength(1);
|
||||
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
for (const [method, path] of [
|
||||
["GET", "/"],
|
||||
["POST", "/"],
|
||||
["GET", "/last-session"],
|
||||
["PUT", "/last-session"],
|
||||
["PATCH", "/1"],
|
||||
["PUT", "/1/content"],
|
||||
["POST", "/1/duplicate"],
|
||||
["POST", "/1/set-default"],
|
||||
["POST", "/1/unset-default"],
|
||||
["POST", "/1/apply"],
|
||||
["DELETE", "/1"],
|
||||
] as const) {
|
||||
const res = await server.request(method, path, {
|
||||
body: { name: "x", payload: layout() },
|
||||
});
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
expect(res.body.required).toBe("workspaces.use");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
@@ -51,3 +53,164 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function workspace(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "Prod",
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: {
|
||||
version: 1,
|
||||
tabs: [{ slotId: "s1", type: "network_graph", label: "Graph" }],
|
||||
activeSlotId: "s1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
},
|
||||
syncId: "sync-1",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
tabCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(workspaces: unknown[]) {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: workspaces })),
|
||||
post: vi.fn(async () => ({ data: workspaces[0] })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyThroughPanel(app: RenderedPluginApp, name: string) {
|
||||
app.renderPanel("workspaces", { active: true });
|
||||
fireEvent.click(await screen.findByText(name));
|
||||
fireEvent.click(
|
||||
await screen.findByText("Apply Workspace", { selector: "button" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(app.shellCalls.some((call) => call.method === "applyLayout")).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("workspaces panel", () => {
|
||||
it("gates its rail item on workspaces.use", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.railItems()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "workspaces",
|
||||
permission: "workspaces.use",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists the user's workspaces", async () => {
|
||||
const api = stubApi([
|
||||
workspace(),
|
||||
workspace({ id: 2, name: "Last Session", kind: "last_session" }),
|
||||
]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
rendered.renderPanel("workspaces", { active: true });
|
||||
|
||||
expect(await screen.findByText("Prod")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(locales.newUi.sidebar.workspaces.lastSession),
|
||||
).toBeTruthy();
|
||||
expect(api.get).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("applies a workspace to the tabs and marks it used", async () => {
|
||||
const saved = workspace();
|
||||
const api = stubApi([saved]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await applyThroughPanel(rendered, "Prod");
|
||||
|
||||
const call = rendered.shellCalls.find((c) => c.method === "applyLayout");
|
||||
expect(call?.args[0]).toEqual(saved.payload);
|
||||
expect(call?.args[1]).toEqual({ name: "Prod" });
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith("/1/apply"));
|
||||
});
|
||||
|
||||
it("keeps a tab from a plugin that is not running and shows its placeholder", async () => {
|
||||
const saved = workspace({
|
||||
payload: {
|
||||
version: 1,
|
||||
tabs: [{ slotId: "s1", type: "not-installed-view", label: "Gone" }],
|
||||
activeSlotId: "s1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
},
|
||||
});
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi([saved]) as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await applyThroughPanel(rendered, "Prod");
|
||||
|
||||
expect(rendered.openedTabs()).toEqual([
|
||||
{ type: "not-installed-view", hostId: undefined, label: "Gone" },
|
||||
]);
|
||||
const tab = rendered.renderOpenedTab(0);
|
||||
expect(
|
||||
tab.querySelector('[data-testid="plugin-view-placeholder"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the default workspace once the shell is ready", async () => {
|
||||
const saved = workspace({ isDefault: true });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
permissions: ["workspaces.use"],
|
||||
ready: true,
|
||||
api: stubApi([saved]) as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
rendered!.shellCalls.find((c) => c.method === "applyLayout")?.args[0],
|
||||
).toEqual(saved.payload),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore or autosave for a user without workspaces.use", async () => {
|
||||
const api = stubApi([workspace({ isDefault: true })]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
permissions: [],
|
||||
ready: true,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(rendered.shellCalls.some((c) => c.method === "applyLayout")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { createWorkspacesApi } from "../../src/frontend/workspaces-api";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: [] })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
}));
|
||||
function fakeClient() {
|
||||
const reply = (data: unknown) => vi.fn(async () => ({ data }));
|
||||
return {
|
||||
get: reply([]),
|
||||
post: reply({ id: 1 }),
|
||||
put: reply({ id: 1 }),
|
||||
patch: reply({ id: 1 }),
|
||||
delete: reply({ success: true }),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
const layout = { version: 1, tabs: [] };
|
||||
|
||||
import {
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceContent,
|
||||
deleteWorkspace,
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
applyWorkspaceServer,
|
||||
getLastSessionWorkspace,
|
||||
saveLastSessionWorkspace,
|
||||
} from "../../src/frontend/workspaces-api";
|
||||
import type { WorkspacePayload } from "@/types/ui-types";
|
||||
describe("workspaces api", () => {
|
||||
it("calls each route relative to the plugin's own mount point", async () => {
|
||||
const client = fakeClient();
|
||||
const api = createWorkspacesApi(client as unknown as PluginApiClient);
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
authApiMock.patch.mockClear();
|
||||
authApiMock.put.mockClear();
|
||||
authApiMock.delete.mockClear();
|
||||
});
|
||||
await api.list();
|
||||
await api.create({ name: "A", color: "#fff", payload: layout });
|
||||
await api.rename(3, { name: "B" });
|
||||
await api.updateContent(3, layout);
|
||||
await api.remove(3);
|
||||
await api.duplicate(3, "C");
|
||||
await api.setDefault(3);
|
||||
await api.unsetDefault(3);
|
||||
await api.apply(3);
|
||||
await api.getLastSession();
|
||||
await api.saveLastSession(layout);
|
||||
|
||||
const samplePayload: WorkspacePayload = {
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeSlotId: null,
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
};
|
||||
|
||||
describe("workspaces-api", () => {
|
||||
it("listWorkspaces GETs /plugin-api/workspaces", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
|
||||
const result = await listWorkspaces();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces");
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
expect(client.get.mock.calls).toEqual([["/"], ["/last-session"]]);
|
||||
expect(client.post.mock.calls).toEqual([
|
||||
["/", { name: "A", color: "#fff", payload: layout }],
|
||||
["/3/duplicate", { name: "C" }],
|
||||
["/3/set-default"],
|
||||
["/3/unset-default"],
|
||||
["/3/apply"],
|
||||
]);
|
||||
expect(client.patch.mock.calls).toEqual([["/3", { name: "B" }]]);
|
||||
expect(client.put.mock.calls).toEqual([
|
||||
["/3/content", { payload: layout }],
|
||||
["/last-session", { payload: layout }],
|
||||
]);
|
||||
expect(client.delete.mock.calls).toEqual([["/3"]]);
|
||||
});
|
||||
|
||||
it("createWorkspace POSTs /plugin-api/workspaces with the payload", async () => {
|
||||
await createWorkspace({
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", {
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
});
|
||||
|
||||
it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => {
|
||||
await renameWorkspace(5, { name: "New Name" });
|
||||
expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", {
|
||||
name: "New Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => {
|
||||
await updateWorkspaceContent(5, samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/content",
|
||||
{
|
||||
payload: samplePayload,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => {
|
||||
await deleteWorkspace(5);
|
||||
expect(authApiMock.delete).toHaveBeenCalledWith("/plugin-api/workspaces/5");
|
||||
});
|
||||
|
||||
it("duplicateWorkspace POSTs /plugin-api/workspaces/:id/duplicate with a name", async () => {
|
||||
await duplicateWorkspace(5, "Test A (copy)");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/duplicate",
|
||||
{
|
||||
name: "Test A (copy)",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("setDefaultWorkspace POSTs /plugin-api/workspaces/:id/set-default", async () => {
|
||||
await setDefaultWorkspace(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/set-default",
|
||||
);
|
||||
});
|
||||
|
||||
it("applyWorkspaceServer POSTs /plugin-api/workspaces/:id/apply", async () => {
|
||||
await applyWorkspaceServer(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/apply",
|
||||
);
|
||||
});
|
||||
|
||||
it("getLastSessionWorkspace GETs /plugin-api/workspaces/last-session", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: null });
|
||||
const result = await getLastSessionWorkspace();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/last-session",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => {
|
||||
await saveLastSessionWorkspace(samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/last-session",
|
||||
{
|
||||
payload: samplePayload,
|
||||
},
|
||||
);
|
||||
it("returns the response body", async () => {
|
||||
const client = fakeClient();
|
||||
client.get = vi.fn(async () => ({ data: [{ id: 7 }] }));
|
||||
const api = createWorkspacesApi(client as unknown as PluginApiClient);
|
||||
expect(await api.list()).toEqual([{ id: 7 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -106,10 +106,7 @@
|
||||
"plugins/web-endpoint/src/frontend/index.tsx",
|
||||
"plugins/web-endpoint/src/frontend/web-endpoint-api.ts",
|
||||
"plugins/web-endpoint/src/frontend/web-endpoint-url.ts",
|
||||
"plugins/web-endpoint/src/frontend/web-endpoint-validation.ts",
|
||||
"plugins/workspaces/src/frontend/WorkspacesPanel.tsx",
|
||||
"plugins/workspaces/src/frontend/index.tsx",
|
||||
"plugins/workspaces/src/frontend/workspaces-api.ts"
|
||||
"plugins/web-endpoint/src/frontend/web-endpoint-validation.ts"
|
||||
],
|
||||
"plugin-to-core": [
|
||||
"plugins/ai/src/backend/egress.ts",
|
||||
@@ -179,8 +176,7 @@
|
||||
"plugins/remote-desktop/src/backend/token-service.ts",
|
||||
"plugins/ssh-terminal/src/backend/index.ts",
|
||||
"plugins/tailscale/src/backend/routes.ts",
|
||||
"plugins/web-endpoint/src/backend/routes.ts",
|
||||
"plugins/workspaces/src/backend/routes.ts"
|
||||
"plugins/web-endpoint/src/backend/routes.ts"
|
||||
],
|
||||
"plugin-to-plugin": [
|
||||
"plugins/ai/src/backend/tools/executor.ts",
|
||||
|
||||
@@ -2839,36 +2839,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_workspaces LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create user_workspaces table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- workspaces end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Stable per-row identity used to match rows across two independently-
|
||||
// seeded databases (the embedded desktop backend and a connected remote
|
||||
|
||||
@@ -152,11 +152,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_user_workspaces_user_id",
|
||||
table: "user_workspaces",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_homepage_items_user_id",
|
||||
table: "homepage_items",
|
||||
|
||||
@@ -1780,36 +1780,7 @@ export const fleetInventory = mysqlTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = mysqlTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: boolean("is_default")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -1781,36 +1781,7 @@ export const fleetInventory = pgTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = pgTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: boolean("is_default")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -1779,36 +1779,7 @@ export const fleetInventory = sqliteTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = sqliteTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: integer("is_default", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -69,7 +69,6 @@ import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
import { UserRepository } from "./user-repository.js";
|
||||
import { VaultProfileRepository } from "./vault-profile-repository.js";
|
||||
import { VaultTokenRepository } from "./vault-token-repository.js";
|
||||
import { WorkspaceRepository } from "./workspace-repository.js";
|
||||
|
||||
/**
|
||||
* The context every repository runs against.
|
||||
@@ -631,13 +630,6 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentWorkspaceRepository(): WorkspaceRepository {
|
||||
return new WorkspaceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("workspace_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the settings cache. Must run during startup on engines without a
|
||||
* synchronous read, before anything calls getCurrentSettingValue.
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { userWorkspaces } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type WorkspaceRecord = typeof userWorkspaces.$inferSelect;
|
||||
|
||||
export interface WorkspaceCreateInput {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdateInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, id: number): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findLastSession(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.kind, "last_session"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDefault(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertLastSession(
|
||||
userId: string,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const existing = await this.findLastSession(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(
|
||||
eq(userWorkspaces.id, existing.id),
|
||||
eq(userWorkspaces.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: WorkspaceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: WorkspaceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
updatedAt: now,
|
||||
},
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
userId: string,
|
||||
id: number,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async setDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
ne(userWorkspaces.id, id),
|
||||
),
|
||||
);
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: true, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async unsetDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: false, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async touchLastUsed(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const result = await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ lastUsedAt: now })
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: string, id: number): Promise<boolean> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return false;
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const owned = await this.listByUser(userId);
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return owned.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
type SyncReferenceEntity,
|
||||
} from "./sync-references.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import {
|
||||
insertReturningWhere,
|
||||
updateReturning,
|
||||
} from "../repositories/returning.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
|
||||
// Primed at import so every consumer of this module - the routes below, the
|
||||
@@ -58,9 +62,24 @@ function entityConfig(entityType: SyncEntityType) {
|
||||
table: entity.table as SyncTable,
|
||||
readOnlyFields: entity.readOnlyFields,
|
||||
singleton: entity.singleton === true,
|
||||
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row takes part in sync. An entity can leave some of its rows out
|
||||
* (a per-install "last session"): they are not pulled, and a push, update or
|
||||
* delete aimed at one is refused.
|
||||
*/
|
||||
export function isSyncedRow(
|
||||
entityType: string,
|
||||
row: Record<string, unknown>,
|
||||
): boolean {
|
||||
const entity = getEntity(entityType);
|
||||
if (!entity?.shouldSync) return true;
|
||||
return entity.shouldSync(row) !== false;
|
||||
}
|
||||
|
||||
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
@@ -259,7 +278,7 @@ router.get(
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const conditions = [eq(table.userId, userId)];
|
||||
if (since && "updatedAt" in table) {
|
||||
@@ -273,8 +292,10 @@ router.get(
|
||||
.from(table as typeof hosts)
|
||||
.where(and(...conditions));
|
||||
|
||||
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
synced.map(async (row) => {
|
||||
const result = await serializeSyncReferences(
|
||||
entityType,
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
@@ -331,12 +352,20 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const { table } = entityConfig(entityType);
|
||||
const { table, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
|
||||
await context.drizzle
|
||||
.delete(table as typeof hosts)
|
||||
.where(locateSyncRow(entityType, userId, syncId));
|
||||
const [target] = (await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(locateRow)
|
||||
.limit(1)) as Record<string, unknown>[];
|
||||
if (target && !shouldSync(target)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
await context.drizzle.delete(table as typeof hosts).where(locateRow);
|
||||
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
@@ -397,10 +426,13 @@ router.post(
|
||||
try {
|
||||
// singleton is still needed below: those tables have no sync_id column
|
||||
// for the insert to populate.
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
if (!shouldSync(payload)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const existingRows = await context.drizzle
|
||||
.select()
|
||||
@@ -408,6 +440,9 @@ router.post(
|
||||
.where(locateRow)
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
if (existing && !shouldSync(existing)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const resolvedPayload = await deserializeSyncReferences(
|
||||
entityType,
|
||||
@@ -433,27 +468,30 @@ router.post(
|
||||
userId,
|
||||
);
|
||||
|
||||
// Through the returning helpers, because MySQL has no RETURNING and a
|
||||
// bare .returning() throws there.
|
||||
let resultRow: Record<string, unknown>;
|
||||
if (existing) {
|
||||
const updatedRows = await context.drizzle
|
||||
.update(table as typeof hosts)
|
||||
.set(encryptedPayload)
|
||||
.where(locateRow)
|
||||
.returning();
|
||||
const updatedRows = await updateReturning(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
encryptedPayload,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||
} else {
|
||||
const insertedRows = await context.drizzle
|
||||
.insert(table as typeof hosts)
|
||||
.values(
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
}) as typeof hosts.$inferInsert,
|
||||
)
|
||||
.returning();
|
||||
const insertedRows = await insertReturningWhere(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
}) as typeof hosts.$inferInsert,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
interface ActorStore {
|
||||
userId: string;
|
||||
/** Where the identity came from, for the audit line. */
|
||||
source: "request" | "asUser";
|
||||
source: "request" | "asUser" | "service";
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<ActorStore>();
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import {
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
|
||||
export type { PluginModule };
|
||||
@@ -330,6 +334,18 @@ export function createPluginContext(
|
||||
define: (definition) => dbDefine(definition) as never,
|
||||
client: () => dbClient() as never,
|
||||
refs: () => dbRefs() as never,
|
||||
// Gated but not audited: it follows every write, and the write itself
|
||||
// went through a client() call that already left an audit line.
|
||||
persist: async () => {
|
||||
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
||||
const { DatabaseSaveTrigger } =
|
||||
await import("../utils/database-save-trigger.js");
|
||||
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
|
||||
},
|
||||
get dialect() {
|
||||
return resolveDatabaseDialect();
|
||||
},
|
||||
},
|
||||
|
||||
sync: {
|
||||
|
||||
@@ -19,8 +19,11 @@ import { sql } from "drizzle-orm";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { DatabaseDialect } from "../database/db/dialect.js";
|
||||
import { tablePrefix, LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
|
||||
export { LEGACY_TABLE_OWNERS };
|
||||
// The splitter is shared with createTestDb in the SDK, so tests apply a
|
||||
// migration exactly the way this runner does.
|
||||
export { LEGACY_TABLE_OWNERS, splitStatements };
|
||||
|
||||
/** Dialect directory names, matching DatabaseDialect exactly. */
|
||||
const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
|
||||
@@ -94,79 +97,6 @@ export function readMigrations(
|
||||
return migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 quote: 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 (quote) {
|
||||
current += char;
|
||||
// '' inside a quoted string is an escaped quote, not a terminator.
|
||||
if (char === quote) {
|
||||
if (next === quote) {
|
||||
current += next;
|
||||
i++;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
lineComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
quote = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables a migration may create or alter.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
export interface ServiceRegistration {
|
||||
@@ -386,9 +387,14 @@ async function invokeGuarded(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (
|
||||
implementation as (...callArgs: unknown[]) => unknown
|
||||
).apply(registration.implementation, args);
|
||||
// Runs as the user the permission was checked for, so the provider reads
|
||||
// the same user from ctx.currentActor() and cannot be told another one.
|
||||
const result = await runAsActor(userId, "service", () =>
|
||||
(implementation as (...callArgs: unknown[]) => unknown).apply(
|
||||
registration.implementation,
|
||||
args,
|
||||
),
|
||||
);
|
||||
|
||||
await context.audit({
|
||||
userId,
|
||||
|
||||
@@ -1,101 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
* through. The implementation lives in the SDK so createTestDb builds exactly
|
||||
* the table the server does; the DDL emitters are there too, because
|
||||
* termix-plugin writes the same SQL this server applies. Re-exported here so
|
||||
* core keeps one import site.
|
||||
*/
|
||||
|
||||
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 "@termix/plugin-sdk/db";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import { columnName } from "@termix/plugin-sdk/ddl";
|
||||
|
||||
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 - the same deliberate approximation repositories/database-context.ts
|
||||
* documents for core.
|
||||
*/
|
||||
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[],
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The DDL emitters live in the SDK, because termix-plugin writes the same SQL
|
||||
// this server applies. Re-exported here so core keeps one import site.
|
||||
export { buildTable } from "@termix/plugin-sdk/table-builder";
|
||||
export {
|
||||
createTableSql,
|
||||
adoptTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
keyedColumns,
|
||||
|
||||
+6
-14
@@ -306,20 +306,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||
// so disabling any of them stops its WS/HTTP server. See
|
||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||
// AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and
|
||||
// Workspaces are also absent for the same reason: each plugin registers
|
||||
// its router with its own dispatcher in database.ts on activate (/ai,
|
||||
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
|
||||
// /workspaces), so disabling any of them makes its routes 404 instead of
|
||||
// leaving a dead import here. Web Endpoint works the same way, except its
|
||||
// dispatcher is mounted by the tunnel service below rather than by
|
||||
// database.ts, since its route is served on port 30003. Automations'
|
||||
// scheduler (due schedules,
|
||||
// dwell rechecks, docker-event polling, history pruning) also starts
|
||||
// from its own activate() rather than here. See plugins/ai,
|
||||
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
|
||||
// plugins/automations, plugins/network-topology, plugins/workspaces and
|
||||
// plugins/web-endpoint.
|
||||
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
|
||||
// Network Topology, Workspaces, Web Endpoint) is absent for the same
|
||||
// reason: each one serves its routes under /plugin-api/<id>/ through
|
||||
// ctx.http on activate, so disabling it answers 503 instead of leaving a
|
||||
// dead import here. Automations' scheduler also starts from its own
|
||||
// activate() rather than here.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import syncRouter, {
|
||||
isSyncedRow,
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
import { registerEntity } from "../../../plugins/sync-registry.js";
|
||||
|
||||
describe("sync route order", () => {
|
||||
it("registers POST /tombstones before the POST /:entityType wildcard", () => {
|
||||
@@ -95,3 +97,25 @@ describe("stripWritePayload", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncedRow", () => {
|
||||
let dispose: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
});
|
||||
|
||||
it("syncs every row of an entity that does not filter", () => {
|
||||
expect(isSyncedRow("hosts", { id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out the rows an entity's shouldSync refuses", () => {
|
||||
dispose = registerEntity("demo", {
|
||||
type: "demoLayouts",
|
||||
table: {},
|
||||
shouldSync: (row) => row.kind !== "local",
|
||||
});
|
||||
expect(isSyncedRow("demoLayouts", { kind: "shared" })).toBe(true);
|
||||
expect(isSyncedRow("demoLayouts", { kind: "local" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,15 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ marker: "db-handle" }),
|
||||
}));
|
||||
|
||||
const saves: string[] = [];
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: { marker: "users" },
|
||||
hosts: { marker: "hosts" },
|
||||
@@ -90,6 +99,39 @@ beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
invalidatePluginPermissionCache();
|
||||
delete process.env.PLUGIN_MAX_KV_KEYS;
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
saves.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.db.persist", () => {
|
||||
it("flushes SQLite to disk under the plugin's name", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual(["plugin_demo_write"]);
|
||||
expect(ctx.db.dialect).toBe("sqlite");
|
||||
});
|
||||
|
||||
it("does nothing on an engine that is already durable", async () => {
|
||||
process.env.DATABASE_DIALECT = "postgres";
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual([]);
|
||||
expect(ctx.db.dialect).toBe("postgres");
|
||||
});
|
||||
|
||||
it("refuses without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.persist()).rejects.toThrow(/db:own/);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.db capability", () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The SDK's test helpers, against a fixture plugin in a temp directory.
|
||||
*
|
||||
* Plugin suites rely on createTestDb applying migrations exactly as the
|
||||
* server does and on createMockCtx enforcing permissions, so a regression in
|
||||
* either would pass every plugin's tests while proving nothing.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
|
||||
let root: string;
|
||||
let pluginDir: string;
|
||||
|
||||
const notes = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable("fleets", { id: id(), userId: refUser(), name: text() }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "migrations", "sqlite", "0001_adopt.sql"),
|
||||
adoptTableSql("sqlite", "fleets", notes).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createTestDb", () => {
|
||||
it("applies the plugin's migrations after the before hook", async () => {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, name TEXT)",
|
||||
);
|
||||
sqlite.exec("INSERT INTO fleets (user_id, name) VALUES ('u1', 'kept')");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt"]);
|
||||
expect(db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all()).toEqual(
|
||||
[{ name: "kept" }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("hands back a database a plugin can query through define and client", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
|
||||
const table = (await db.database.define(notes)) as never;
|
||||
const drizzle = (await db.database.client()) as {
|
||||
insert: (t: never) => { values: (v: object) => Promise<unknown> };
|
||||
select: () => { from: (t: never) => Promise<unknown[]> };
|
||||
};
|
||||
await drizzle.insert(table).values({ userId: "u1", name: "n" });
|
||||
await db.database.persist();
|
||||
|
||||
expect(await drizzle.select().from(table)).toEqual([
|
||||
{ id: 1, userId: "u1", name: "n" },
|
||||
]);
|
||||
expect(db.persisted).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockCtx permissions", () => {
|
||||
const respond = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
it("passes everything when no permissions are given", async () => {
|
||||
const { ctx } = createMockCtx({ pluginId: "demo" });
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves short names and refuses what the user does not hold", async () => {
|
||||
const { ctx } = createMockCtx({
|
||||
pluginId: "demo",
|
||||
permissions: ["demo.use", "hosts.view"],
|
||||
});
|
||||
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
expect(await ctx.rbac.has("hosts.view")).toBe(true);
|
||||
expect(await ctx.rbac.has("manage")).toBe(false);
|
||||
|
||||
const res = respond();
|
||||
let passed = false;
|
||||
ctx.rbac.require("manage")({} as never, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatchObject({ required: "demo.manage" });
|
||||
});
|
||||
|
||||
it("uses the router factory and follows setActor", () => {
|
||||
const router = {};
|
||||
const mock = createMockCtx({
|
||||
pluginId: "demo",
|
||||
capabilities: ["network:serve"],
|
||||
router: () => router,
|
||||
});
|
||||
expect(mock.ctx.http.router()).toBe(router);
|
||||
|
||||
mock.setActor("u9");
|
||||
expect(mock.ctx.currentActor()).toBe("u9");
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,16 @@ describe("service registry", () => {
|
||||
expect(audits[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the provider as the user the permission was checked for", async () => {
|
||||
const { getActor } = await import("../../plugins/actor.js");
|
||||
register({
|
||||
implementation: { hello: async () => getActor() ?? "nobody" },
|
||||
});
|
||||
const { handle } = handleFor(true, "user-7");
|
||||
|
||||
await expect(handle.hello("x")).resolves.toBe("user-7");
|
||||
});
|
||||
|
||||
it("denies without the permission and never reaches the provider", async () => {
|
||||
const hello = vi.fn(async () => "should not run");
|
||||
register({ implementation: { hello } });
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
refHost,
|
||||
prefixedTableName,
|
||||
tablePrefix,
|
||||
adoptLegacyTable,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
adoptTableSql,
|
||||
createTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
@@ -253,3 +256,56 @@ describe("buildTable", () => {
|
||||
expect(Object.keys(getTableColumns(table))).toContain("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptTableSql", () => {
|
||||
const adopted = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable(
|
||||
"fleets",
|
||||
{ id: id(), name: text().notNull().default("x") },
|
||||
{ indexes: [{ name: "idx_fleets_name", columns: ["id"] }] },
|
||||
),
|
||||
);
|
||||
|
||||
it("creates the legacy table when missing, indexes it, then renames it", () => {
|
||||
const sqlite = adoptTableSql("sqlite", "fleets", adopted);
|
||||
expect(sqlite[0]).toMatch(/^CREATE TABLE IF NOT EXISTS "fleets" \(/);
|
||||
expect(sqlite[1]).toBe(
|
||||
'CREATE INDEX IF NOT EXISTS "idx_fleets_name" ON "fleets" ("id");',
|
||||
);
|
||||
expect(sqlite[2]).toBe('ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";');
|
||||
|
||||
expect(adoptTableSql("postgres", "fleets", adopted).at(-1)).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no indexes on MySQL, which has no IF NOT EXISTS for them", () => {
|
||||
const mysql = adoptTableSql("mysql", "fleets", adopted);
|
||||
expect(mysql).toHaveLength(2);
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a definition that adopts nothing, and a table no plugin may adopt", () => {
|
||||
expect(() => adoptTableSql("sqlite", "demo", definition)).toThrow(
|
||||
/does not adopt/,
|
||||
);
|
||||
expect(() => adoptLegacyTable("users", definition)).toThrow(
|
||||
/not a legacy core table/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits into statements the runner applies one at a time", () => {
|
||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||
expect(splitStatements(joined)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -455,24 +455,6 @@ export type WorkspacePayload = {
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
export type Workspace = {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
payload: WorkspacePayload;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
tabCount: number;
|
||||
};
|
||||
|
||||
export type Snippet = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -50,6 +50,12 @@ function load(): Promise<CachedPermissions> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/** The same answer usePermissions().has gives, for code outside a component. */
|
||||
export async function hasPermission(permission: string): Promise<boolean> {
|
||||
const { permissions, isAdmin } = await load();
|
||||
return matchesPermission(permissions, isAdmin, permission);
|
||||
}
|
||||
|
||||
export function notifyPermissionsChanged(): void {
|
||||
cache = null;
|
||||
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||
|
||||
@@ -43,6 +43,8 @@ import { shell, tabsApi } from "./shell-bridge";
|
||||
import { withPluginScope } from "./scope";
|
||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { hasPermission } from "@/hooks/use-permissions";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface PluginAppHandle {
|
||||
app: TermixApp;
|
||||
@@ -118,6 +120,9 @@ export function createPluginApp(
|
||||
after: item.after,
|
||||
order: item.order,
|
||||
pluginId,
|
||||
permission: item.permission
|
||||
? resolvePluginPermission(pluginId, item.permission)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -339,6 +344,20 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
options as Record<string, unknown>,
|
||||
)) as TermixApp["t"],
|
||||
hasPermission: async (permission) => {
|
||||
try {
|
||||
return await hasPermission(
|
||||
resolvePluginPermission(pluginId, permission),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
api: pluginHostBridge.getApi(pluginId),
|
||||
wsUrl: (path, options) =>
|
||||
pluginWsUrl(pluginId, path, options as never) as ReturnType<
|
||||
|
||||
@@ -56,6 +56,15 @@ export function resolvePluginPermission(
|
||||
|
||||
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
|
||||
|
||||
/** Test seam: renderWithApp's `api` option stands in for the real client. */
|
||||
export function setPluginApiForTesting(
|
||||
pluginId: string,
|
||||
client: ReturnType<typeof createPluginApi> | null,
|
||||
): void {
|
||||
if (client) apiClients.set(pluginId, client);
|
||||
else apiClients.delete(pluginId);
|
||||
}
|
||||
|
||||
function getApi(pluginId: string) {
|
||||
let client = apiClients.get(pluginId);
|
||||
if (!client) {
|
||||
|
||||
@@ -58,6 +58,9 @@ export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
|
||||
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
|
||||
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
|
||||
|
||||
// Design tokens: the colour swatches folders, workspaces and tags pick from.
|
||||
export { FOLDER_COLORS } from "@/lib/theme";
|
||||
|
||||
// Shell context
|
||||
export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
|
||||
@@ -44,9 +44,18 @@ import { getSlotContributions, listActions } from "@/shell/action-registry";
|
||||
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import { createPluginApp } from "./app";
|
||||
import { installPluginHostBridge } from "./bridge";
|
||||
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
|
||||
import { setPluginSummaries, setFrontendState } from "./plugin-store";
|
||||
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
|
||||
import {
|
||||
notifyShellReady,
|
||||
resetShellBridge,
|
||||
setShellCallbacks,
|
||||
setShellHosts,
|
||||
setShellLayoutProvider,
|
||||
} from "./shell-bridge";
|
||||
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
||||
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
||||
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||
const record =
|
||||
@@ -78,7 +87,7 @@ function wrap(element: ReactElement): HTMLElement {
|
||||
).container;
|
||||
}
|
||||
|
||||
function missing(kind: string, id: string): never {
|
||||
function missing(kind: string, id: string | number): never {
|
||||
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
|
||||
}
|
||||
|
||||
@@ -101,12 +110,45 @@ export async function renderPlugin(
|
||||
} as PluginManifest;
|
||||
|
||||
installPluginHostBridge();
|
||||
resetShellBridge();
|
||||
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
|
||||
setShellHosts((options.hosts ?? []) as unknown as Host[]);
|
||||
const hosts = (options.hosts ?? []) as unknown as Host[];
|
||||
setShellHosts(hosts);
|
||||
const shellCalls: ShellCall[] = [];
|
||||
const shell = recordingShell(shellCalls);
|
||||
setShellCallbacks(shell);
|
||||
|
||||
// Stands in for AppShell's layout: applying one runs the shell's own
|
||||
// restore rules and records which tabs it would open.
|
||||
let layout = options.layout ?? null;
|
||||
const opened: Array<{ type: string; hostId?: number; label: string }> = [];
|
||||
setShellLayoutProvider({
|
||||
getLayout: () => layout,
|
||||
applyLayout: async (next, applyOptions) => {
|
||||
shellCalls.push({ method: "applyLayout", args: [next, applyOptions] });
|
||||
layout = next;
|
||||
opened.length = 0;
|
||||
const skipped: string[] = [];
|
||||
const tabs = (next.tabs ?? []) as WorkspaceTabSnapshot[];
|
||||
for (const snapshot of tabs) {
|
||||
const target = resolveLayoutTabTarget(snapshot, hosts);
|
||||
if (target.kind === "skip") {
|
||||
skipped.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||
continue;
|
||||
}
|
||||
opened.push({
|
||||
type: snapshot.type,
|
||||
hostId:
|
||||
"host" in target && target.host
|
||||
? Number(target.host.id)
|
||||
: undefined,
|
||||
label: snapshot.customLabel ?? snapshot.label,
|
||||
});
|
||||
}
|
||||
return { skipped };
|
||||
},
|
||||
});
|
||||
|
||||
const summary: PluginSummary = {
|
||||
id: pluginId,
|
||||
name: manifest.name,
|
||||
@@ -128,11 +170,16 @@ export async function renderPlugin(
|
||||
);
|
||||
}
|
||||
|
||||
setPluginApiForTesting(
|
||||
pluginId,
|
||||
(options.api as Parameters<typeof setPluginApiForTesting>[1]) ?? null,
|
||||
);
|
||||
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
|
||||
guest: options.guest,
|
||||
});
|
||||
await plugin.activate(handle.app);
|
||||
setFrontendState(pluginId, "active");
|
||||
if (options.ready) notifyShellReady();
|
||||
|
||||
const mine = <T extends { pluginId?: string }>(items: T[]) =>
|
||||
items.filter((item) => item.pluginId === pluginId);
|
||||
@@ -154,6 +201,7 @@ export async function renderPlugin(
|
||||
mine(listRegisteredRailItems()).map((item) => ({
|
||||
id: item.id,
|
||||
hidden: item.hidden,
|
||||
permission: item.permission,
|
||||
})),
|
||||
tabs: () => mine(listTabTypes()).map((def) => def.id),
|
||||
panels: () => mine(listPanels()).map((panel) => panel.id),
|
||||
@@ -195,6 +243,28 @@ export async function renderPlugin(
|
||||
);
|
||||
},
|
||||
|
||||
openedTabs: () => opened.map((tab) => ({ ...tab })),
|
||||
|
||||
renderOpenedTab(index) {
|
||||
const tab = opened[index] ?? missing("an opened tab at index", index);
|
||||
const def = getTabType(tab.type);
|
||||
if (!def) {
|
||||
// What tabUtils renders for a tab no running plugin registered.
|
||||
return wrap(<PluginViewPlaceholder kind="tab" viewId={tab.type} />);
|
||||
}
|
||||
const Component = def.component;
|
||||
return wrap(
|
||||
<Component
|
||||
tab={tabRecord(tab.type)}
|
||||
label={tab.label}
|
||||
isVisible
|
||||
isFocusedPane
|
||||
handleRef={null}
|
||||
shell={shell}
|
||||
/>,
|
||||
);
|
||||
},
|
||||
|
||||
renderPanel(id, props = {}) {
|
||||
const panel = getPanel(id) ?? missing("a panel", id);
|
||||
const Component = panel.component;
|
||||
@@ -260,6 +330,7 @@ export async function renderPlugin(
|
||||
} finally {
|
||||
handle.dispose();
|
||||
setFrontendState(pluginId, "inactive");
|
||||
setPluginApiForTesting(pluginId, null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,8 +45,34 @@ const CORE_SINGLETON = new Set([
|
||||
"ssh-tools",
|
||||
]);
|
||||
|
||||
/** Core tab types that are never part of a saved arrangement. */
|
||||
const CORE_UNSAVED = new Set([
|
||||
"dashboard",
|
||||
"local-terminal",
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"split-screen",
|
||||
"sftp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A tab type no running plugin has registered: its plugin is disabled,
|
||||
* failed, still loading or not installed. The shell shows a placeholder for
|
||||
* it, and a layout keeps it so nothing is lost when the plugin comes back.
|
||||
*/
|
||||
export function isUnregisteredPluginTabType(type: string): boolean {
|
||||
return (
|
||||
!CORE_UNSAVED.has(type) &&
|
||||
!CORE_CAPTURABLE.has(type) &&
|
||||
type !== "serial" &&
|
||||
!getTabType(type)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCapturableTabType(type: string): boolean {
|
||||
if (CORE_CAPTURABLE.has(type)) return true;
|
||||
if (isUnregisteredPluginTabType(type)) return true;
|
||||
const def = getTabType(type);
|
||||
return !!def && def.inLayouts !== false;
|
||||
}
|
||||
@@ -142,6 +168,12 @@ export function resolveLayoutTabTarget(
|
||||
return { kind: "singleton", host };
|
||||
}
|
||||
|
||||
// Reopened as a placeholder rather than dropped, so applying a workspace
|
||||
// while a plugin is off does not quietly lose its tabs.
|
||||
if (!host && isUnregisteredPluginTabType(snapshot.type)) {
|
||||
return { kind: "singleton" };
|
||||
}
|
||||
|
||||
return host ? { kind: "host", host } : { kind: "skip" };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, visibleRailItems } from "./rail-items";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -52,12 +52,13 @@ type RailItem =
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(
|
||||
items: RailItemDef[],
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [];
|
||||
for (const item of visibleRailItems()) {
|
||||
for (const item of items) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
@@ -192,8 +193,8 @@ export function AppRail({
|
||||
};
|
||||
}, [menuPos]);
|
||||
|
||||
// Plugins add and remove rail items at runtime.
|
||||
useRailItems();
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -246,7 +247,12 @@ export function AppRail({
|
||||
...hiddenTabs,
|
||||
...(isRemoteSyncConnected ? [] : ["termix-id"]),
|
||||
]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems,
|
||||
splitMode,
|
||||
t,
|
||||
effectiveHiddenTabs,
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
@@ -69,6 +70,8 @@ export interface RailItemDef {
|
||||
order?: number;
|
||||
/** Set for items a plugin registered. */
|
||||
pluginId?: string;
|
||||
/** Role permission the user needs to see it at all, as a full id. */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
@@ -250,13 +253,37 @@ function railItemsSnapshot(): RailItemDef[] {
|
||||
return railSnapshot;
|
||||
}
|
||||
|
||||
/** visibleRailItems() as a hook, re-rendering when plugins change it. */
|
||||
/**
|
||||
* Drops items gated on a permission the user lacks. Until permissions load,
|
||||
* gated items stay hidden rather than flashing in and out.
|
||||
*/
|
||||
export function permittedRailItems(
|
||||
items: RailItemDef[],
|
||||
permissions: { has: (permission: string) => boolean; loaded: boolean },
|
||||
): RailItemDef[] {
|
||||
if (!items.some((item) => item.permission)) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
!item.permission ||
|
||||
(permissions.loaded && permissions.has(item.permission)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* visibleRailItems() as a hook, re-rendering when plugins change it, without
|
||||
* the items the user's permissions hide.
|
||||
*/
|
||||
export function useRailItems(): RailItemDef[] {
|
||||
return useSyncExternalStore(
|
||||
const items = useSyncExternalStore(
|
||||
registeredRailItems.subscribe,
|
||||
railItemsSnapshot,
|
||||
railItemsSnapshot,
|
||||
);
|
||||
const { has, loaded } = usePermissions();
|
||||
return useMemo(
|
||||
() => permittedRailItems(items, { has, loaded }),
|
||||
[items, has, loaded],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -358,11 +358,49 @@ describe("plugin tab types in layouts", () => {
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("leaves out a plugin tab type nobody registered", () => {
|
||||
it("keeps a plugin tab type nobody registered, so a disabled plugin loses nothing", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "x", type: "some-plugin-tab" }),
|
||||
makeTab({ id: "x", type: "some-plugin-tab", data: { a: 1 } }),
|
||||
makeTab({ id: "d", type: "dashboard" }),
|
||||
makeTab({ id: "h", type: "host-manager" }),
|
||||
]);
|
||||
expect(snapshots).toEqual([]);
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["some-plugin-tab"]);
|
||||
expect(snapshotData(snapshots[0])).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("reopens a hostless tab of a missing plugin as a placeholder", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{ slotId: "s", type: "some-plugin-tab", label: "Gone" },
|
||||
[],
|
||||
),
|
||||
).toEqual({ kind: "singleton" });
|
||||
});
|
||||
|
||||
it("reopens a missing plugin's host tab while its host exists", () => {
|
||||
const host = makeHost({ syncId: "sync-1" });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-1",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "host", host });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-deleted",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "skip" });
|
||||
});
|
||||
|
||||
it("carries a tab's plugin data and reads the older fleetId field", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
hideableRailIds,
|
||||
permittedRailItems,
|
||||
promotableIds,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
@@ -230,3 +231,39 @@ describe("railItemLabel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permittedRailItems", () => {
|
||||
const items = [
|
||||
{ id: "open", icon: Boxes, labelKey: "nav.hosts" },
|
||||
{
|
||||
id: "gated",
|
||||
icon: Boxes,
|
||||
labelKey: "nav.hosts",
|
||||
permission: "demo.use",
|
||||
},
|
||||
];
|
||||
|
||||
it("hides an item gated on a permission the user lacks", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: () => false,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
|
||||
it("shows it once the user holds the permission", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: (permission) => permission === "demo.use",
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open", "gated"]);
|
||||
});
|
||||
|
||||
it("keeps gated items hidden until permissions have loaded", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: false,
|
||||
has: () => true,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user