mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: switch to calver, bump version to 26.10.0 * fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * feat(ai): add remote coding agent sessions for SSH hosts (#1545) * feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL * test: run the posix-only ai agent tests on linux only * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 * fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi * chore: rework plugin readmes, json changelogs and standalone repo setup * chore: move plugins to their own repos and drop the plugins folder * chore(deps-dev): bump globals (#1560) Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: drop the 2.8 columns, moves and aliases kept for a downgrade * chore: remove the 2.9 finish list * feat: port the demo redesign with Manage and Settings tabs * chore: pin the plugin signing key and use TERMIX_PAT in workflows * chore: rename the plugin sdk to @termix-ssh/plugin-sdk * feat: add a publish workflow for the plugin sdk * chore: cleanup * fix: harden plugin sockets, public routes, prebuilt binaries and login fallback * chore: add a 2.9.1 upgrade check for postgres and mysql * fix: stop full database rewrites on every plugin db call * fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows * feat: pin the official plugins in the docker image * feat: implement plugin management UI * feat: shared list kit, settings search, rail order and ui consistency fixes * chore: run all workflows on blacksmith runners * chore: split dashboard quick actions into quarters and drop host status row lines * fix: open plain settings from the profile button, navigation only from the rail menu * feat: consent check, developer mode file installs, install counts and issue links for plugins * feat: row detail options for hosts and credentials, open manage from sidebar header * fix: unify connection failure screens and rebuild stale local plugins * feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev * chore: clean up npm scripts, make npm run dev the full runner and drop concurrently * fix: settle quick connect panel loads in tests so they stop flaking * fix: stop the dev runner rebuilding plugins when windows reports file reads as changes * chore: update the plugin sdk ui export snapshot * feat: full screen onboarding with a plugin picker and per step versions * feat: changelog.md release notes, v tags, plugin release notes and videos * chore: add temp changelog * chore: release the plugin sdk as 1.0.1 * feat: show only the latest plugin release notes and cap long ones * chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub * chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN * chore: renew mac app store provisioning profile * chore: drop docs job from release workflow * chore: run sdk publish on github hosted runner * chore: remove dependabot * chore: general file cleanup * chore: rewrite readme and taglines for plugin-based termix * chore: drop optional from ai plugin row in readme * feat: per-repo issue forms and prefilled bug reports instead of the support repo * feat: add report a bug link to the your account settings section * fix: open settings goes to the plugin's page and hides when it has none * feat: show plugin features from the manifest on the plugin page * fix: retry failed or blocked plugins on restart instead of leaving them disabled * fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling * feat: request a feature link for plugins and use the bug form for unverified plugins * feat: beta channels for core and plugins with one stable/beta release workflow * fix(sync): resolve shared hosts for connect-only recipients (#1568) * fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) * fix(ssh): normalize PKCS8 private keys before connecting (#1570) * fix(sidebar): reclaim space from hidden folder actions (#1571) * fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) * fix: ask for a push/TOTP menu choice as text instead of a push confirm * fix: keep client tunnels open when one of their connections fails (Support#1417) * fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) * fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) * fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) * fix: refresh cached plugin translations when only a translation changes (Support#1411) * fix: import sshpk as a default so the backend starts under ESM * feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 * fix: drop the ./ from the plugin sdk bin path so npm stops warning * chore: name core and sdk releases Termix vX and Plugin SDK vX * fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) * fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) * fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) * fix: refuse sessionless tokens and keep the secrets file owner only * feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) * chore: standardize repo setup files * chore: remove unused knip config * chore: add LICENSE and use Node 24 from .nvmrc * chore: update translation guidance, only en.json needs editing * feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 * fix: header separators render as borders and center the status divider * fix: lighter confirm prompt overlay to stop lag when it opens * fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 * fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK * fix: keep plugin tailwind variants in core's layer so overrides keep their order * fix: manage tab refetches before giving up on a host or credential it cannot find * fix: tab buttons like share show as soon as the terminal is ready * fix: folder rows no longer nest action buttons inside a button * fix: repin remote-desktop after its 1.0.0 re-release * chore: repin bundled plugins after the 1.0.0 re-release * fix: recent activity rows use the same separator as host status * feat: upload banner * fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 * fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks * fix: bundled plugins path for source runs and backend cleanup * fix: load plugin strings on guest pages, plugin wording and drop unused keys * fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms * docs: 26.10.0 changelog * fix: current version in the version route example * chore: stop pushing the release- docker tag * feat: remove the donation reminder and its column * docs: note the removed donation reminder * fix: link server wizard always opens inline, onboarding card grows for it * feat: plugin text columns are longtext on mysql so they are not capped at 64KB * fix: linked desktops keep a plugin when its update from the server fails * chore: default mobile download links to v tags * fix: sso only accounts cannot sign in with a passkey or other local method * feat: note in the plugins tab that community plugins are coming * fix: plugin validate accepts beta versions * chore: pin bundled plugins rebuilt on sdk 1.0.5 * fix: skip locale fetches for disabled plugins * fix: show translated names for the built-in admin and user roles * fix: show host keys as SHA256 fingerprints like ssh-keygen * fix: host editor scrolls to a newly enabled protocol section * fix: pass the host to dashboard host row plugins * fix: plugin cards show dividers between author, version and category * fix: refuse plugin archives with paths outside the plugin folder * fix: theme picker shows one dropdown arrow * fix: read activity and audit times as UTC so they show in local time * fix: show the real error for a wrong password or code on plugin sign in * fix: explain that external sign in needs Auto-create external accounts * fix: clear leftover plugin unpack folders at startup * fix: shared session links open for guests again * fix: share panels no longer list yourself as a share target * fix: a host update without an auth type keeps the saved password * fix: guest pages get the tab ids a guest plugin registers * fix: refuse a 2.8 database, which already had a plugins table * fix: dashboard cards fit on a phone * fix: desktop setup no longer asks to secure the local account * fix: the desktop app keeps the desktop layout in a narrow window * fix: the desktop app shows the rail and no phone bar in a narrow window * fix: name every sync type in the Sync panel and pluralize the link prompt * chore: update the sdk ui snapshot for the host key dialog * fix: turning a plugin back on restarts the plugins it turned off * fix: quick connect asks to trust the host key * fix: revoking a manager also removes the shares they handed out * fix: database export works for users with TOTP on (#1426) * fix: rdp and vnc logins use the folder credential when none is picked (#1429) * fix: imported hosts get status checks without a restart * fix: re-pin bundled plugins after 1.0.0 re-release * fix: re-pin bundled plugins after search bar fix * chore: update app icon to smaller version * chore: round the readme logo, keep square source for app icons * feat: grid columns and table view in the plugins tab * fix: match the host key copy button to the fingerprint height * fix: use brand color for toggles in sync settings * fix: re-pin bundled plugins after 1.0.0 re-release * fix: web terminals failing auth behind a proxy that rewrites Host (Support#651) * fix: re-pin snippets and termix-identity after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * chore: remove Crowdin from the release workflow * chore: remove crowdin config --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
3656 files changed
+116210
-460999
No files matched your search
@@ -0,0 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Fixed
|
||||
|
||||
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
|
||||
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
|
||||
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
|
||||
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
|
||||
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
|
||||
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- `termix-plugin build` puts a plugin's own Tailwind classes in core's utilities layer, so responsive, hover and group variants and `className` overrides on SDK components work. Plain classes core already ships stay below core's utilities as before, while variants core ships go with the plugin's own so `max-h-56 md:max-h-none` keeps its order. Rebuild a plugin to pick it up
|
||||
|
||||
## 1.0.3
|
||||
|
||||
Plugin API 1.2.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `docs` manifest field: an https link to the plugin's docs. Termix shows it as a Documentation link on the plugin's page and in its settings
|
||||
- `env` manifest field: the environment variables the plugin reads, listed in its docs and on its page
|
||||
- `app.docs` and `useDocsUrl()` for links into the plugin's own docs, and `DocsLink` in `@termix-ssh/plugin-sdk/ui`
|
||||
- `PanelShell` takes a `docs` link and shows it as a book icon in the header
|
||||
- `@termix-ssh/plugin-sdk/openapi` with `buildOpenApi()`, and `termix-plugin openapi`, which writes `dist/openapi.json` from the `@openapi` blocks in `src/backend`. `build` runs it too
|
||||
- `@termix-ssh/plugin-sdk/docs` with `pluginDocsUrl()`, `pluginDocsPage()` and `pluginEnvVars()`
|
||||
- `termix-plugin validate` warns when `docs/index.md` is missing or the source reads an env var the manifest does not list
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Added
|
||||
|
||||
- `features` manifest field: up to 20 short lines about what the plugin does, shown on its page. `pluginFeatures()` reads them
|
||||
|
||||
### Changed
|
||||
|
||||
- `parseChangelog()` and `validateChangelog()` flag a `## Unreleased` section. Notes go under the version they ship in
|
||||
|
||||
### Removed
|
||||
|
||||
- `Changelog.unreleased`
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Added
|
||||
|
||||
- Plugin API 1.1
|
||||
- `app.registerOnboardingStep()` adds a step to onboarding, with `OnboardingStepProps` and `OnboardingStepContribution`. Needs `engine.api` 1.1
|
||||
- `video` manifest field: a YouTube link shown at the top of the plugin's page, read with `youtubeVideoId()`
|
||||
- `@termix-ssh/plugin-sdk/changelog` parses and checks a plugin's `CHANGELOG.md`
|
||||
- `termix-plugin changelog` prints one version's notes from `CHANGELOG.md`
|
||||
- `termix-plugin dev` installs a plugin on a running server and reinstalls it on every change. Needs developer mode and an admin API key
|
||||
- `renderOnboardingStep()` and `registered.onboardingSteps()` in `renderWithApp()`
|
||||
- `AddButton`, `FormFooter`, `ListRow`, `ListRowAction`, `ListRowFolder`, `ListBadge` and `PanelList` in `@termix-ssh/plugin-sdk/ui`
|
||||
- `PanelSearch` takes `disabled`, `title`, `onEnter` and `inputRef`, and `SurfaceScope` takes `onClose`
|
||||
|
||||
### Changed
|
||||
|
||||
- Release notes moved from `CHANGELOG.json` to `CHANGELOG.md`, which `pack` now ships. `validate` flags a leftover `CHANGELOG.json`
|
||||
- `ConnectionScreen` dropped `errorMessage` and `retryLabel` and gained `onClose`. `ConnectionLogPanel` dropped `position`
|
||||
- Plugin tests run in worker threads, since forked workers crash now and then on Windows
|
||||
- The test setup stubs `ResizeObserver` for Radix controls under jsdom
|
||||
- `onboarding` is a reserved plugin id
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The `onboarding.*` action slots. Use `registerOnboardingStep()`
|
||||
|
||||
### Removed
|
||||
|
||||
- `schema/changelog.schema.json`
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Backend, frontend, database, manifest, capability and settings types for plugin API 1.0
|
||||
- Testing helpers and a Vitest preset for plugin tests
|
||||
- The `@termix-ssh/plugin-sdk/ui` component set
|
||||
- The `termix-plugin` CLI to build, validate, test, pack, sign and write migrations
|
||||
@@ -1,228 +0,0 @@
|
||||
# Finish list
|
||||
|
||||
Everything a Phase B/C step left for D0 is done. What is left needs a person
|
||||
with a real install, a real device or a repo outside this one.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
|
||||
prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view,
|
||||
snippets sent to a terminal, reconnect after a network drop and after a
|
||||
page reload (session reattach), and the local terminal in the desktop app.
|
||||
- Serial: the Electron backend path against a real device path/COM port, and
|
||||
the Web Serial browser picker in Chrome or Edge.
|
||||
- Session sharing: a link share (read-only and read-write) opened in a private
|
||||
window, a user share joined from Active Connections, revoke and "end for
|
||||
everyone", and a collab room with two members, control hand-off, a guest
|
||||
link, rotate and end.
|
||||
- Session recording: an SSH session recorded end to end (playback matches what
|
||||
happened, including a resize mid-session), a host with recording switched
|
||||
off records nothing, retention prunes an old recording on schedule, a guacd
|
||||
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
|
||||
user anonymizes their recordings instead of removing them.
|
||||
- Host status and metrics: on a 2.8 database, hosts whose status checks were
|
||||
off stay off and a custom interval survives; the host list dot goes
|
||||
reachable, then online once the Host Metrics tab or a terminal logs in;
|
||||
an RDP-only host on a custom port shows online; the metrics tab, the
|
||||
dashboard host bars, the homepage host status widget, the metrics chart
|
||||
widget, the terminal toolbar bars and the file manager's disk bar; a
|
||||
manager card (services, packages) and a health check; TOTP on the metrics
|
||||
connect; the admin metrics interval, history retention and new-host default;
|
||||
the temperature unit; disabling host metrics leaves every other plugin's
|
||||
SSH working, then enabling it again.
|
||||
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
|
||||
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
|
||||
Sharing over VNC with no username; RDP recording (and none with
|
||||
session-recording off or the host's switch off); file transfer through the
|
||||
RDP drive, drag and drop included; clipboard both ways; sharing an RDP
|
||||
session by link (read-only and read-write) and presenting it in a collab
|
||||
room; opening Windows Remote Desktop from the desktop app on Windows;
|
||||
changing the guacd URL in admin settings without a restart; turning Remote
|
||||
Desktop off in admin (actions disappear, connects answer 403); a 2.8 host
|
||||
that only had `connection_type = rdp` comes up as RDP with SSH off; RDP user
|
||||
defaults apply to a saved host; Quick Connect over RDP and VNC; the desktop
|
||||
app with a host on the remote server and one on This device.
|
||||
- Docker: a 2.8 host with Docker on (and one on Podman) keeps its switch and
|
||||
runtime; list, start, stop, logs, stats and the console on a Linux host;
|
||||
TOTP (including a wrong code first) and Warpgate on the connect; a host
|
||||
with no stored password asks for credentials; the homepage Docker widget;
|
||||
an automation Docker step and a `docker_event` trigger; disable Docker with
|
||||
a console open, then enable it and open another.
|
||||
- Automations: on a 2.8 database the automations, their run history and
|
||||
their channel links are there; a schedule fires; a webhook fires on both
|
||||
`/plugin-api/automations/webhook/<token>` and the old
|
||||
`/automations/webhook/<token>`; a metric threshold on a host nobody has
|
||||
open still fires (headless viewers); a `docker_event` trigger and a Docker
|
||||
step; a notify step to webhook, ntfy and Discord channels, and to a LAN
|
||||
ntfy with the private opt-in plus the admin allowlist; disable Docker and
|
||||
the automation shows "Needs docker", its scheduled runs are recorded as
|
||||
skipped and the others keep running, then enable it again; the AI
|
||||
assistant lists and creates automations, and says they are unavailable
|
||||
with automations off.
|
||||
- Homepage: on a 2.8 database every widget, its layout and every service
|
||||
link are there; add, move, resize, edit and delete a widget of each type;
|
||||
the favicon, RSS, ping and custom API widgets against a real remote URL,
|
||||
and each refuses a private/loopback target; the dashboard's Service
|
||||
Links and Homepage Preview cards; the Dashboard/Homepage toggle and its
|
||||
copy-link and open-full-view buttons; `?view=homepage`; docker's, tunnels'
|
||||
and file-manager's own homepage widgets still work with the homepage
|
||||
plugin disabled; the AI assistant lists homepage items and says
|
||||
unavailable with homepage off; disable homepage in admin (the tab, its
|
||||
dashboard cards and the toggle disappear) then enable it again.
|
||||
- TOTP and passkeys: on a 2.8 database a TOTP user is still asked for a
|
||||
code (and a backup code works once); set up TOTP from Settings > Security,
|
||||
add a second device, regenerate backup codes, disable it; enabling signs
|
||||
out other sessions; "remember me" skips it next time; disable the totp
|
||||
plugin and a TOTP user's login is refused until an admin resets their
|
||||
factors; register a passkey, sign in with it with and without a PIN (no
|
||||
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
|
||||
- SSO and LDAP: on a 2.8 database every SSO provider and LDAP directory is
|
||||
there, in Settings > Plugins > Single sign-on and > LDAP; an existing
|
||||
provider still signs in through its identity provider without changing
|
||||
anything there (its redirect URI shows `/users/oidc/callback`); add the
|
||||
new redirect URI at the provider, switch it over and sign in again; a new
|
||||
provider, GitHub and Google; an existing SSO user and an existing LDAP
|
||||
user land in their old accounts; the env-configured provider; admin group
|
||||
and role map; back-channel logout from Keycloak ends the session; the
|
||||
desktop app's system browser login and Termix-Mobile's login; silent
|
||||
sign-in; a second factor after an external login with the admin setting
|
||||
on and off; disable each plugin and its buttons go away and its URLs stop
|
||||
signing anyone in, then enable it again.
|
||||
- OPKSSH and Warpgate: on a 2.8 Docker install with OPKSSH set up, the config
|
||||
shows up under the plugin, the admin page shows the old redirect URI and a
|
||||
terminal sign-in to an OPKSSH host works through the provider chooser
|
||||
without touching the identity provider; switch to the new URI, register
|
||||
it and sign in again; a second connect within 24 hours skips the browser;
|
||||
an offline Docker install uses the baked binary; disable the opkssh plugin
|
||||
and the host says it needs it. A Warpgate host's toggle is on in the host
|
||||
editor's Plugins group after the upgrade, and the terminal, file manager
|
||||
and docker each show the sign-in dialog and connect after it.
|
||||
- Vault: on a 2.8 database every Vault profile is there and each Vault host
|
||||
still has its profile selected in the host editor; the admin page shows the
|
||||
old redirect URI; a terminal connect opens Vault's sign-in, signs a
|
||||
certificate and connects, and a second connect skips the browser until the
|
||||
certificate expires; create, edit, share and delete a profile from the host
|
||||
editor (sharing only offered with "Share Vault profiles"); switch to the new
|
||||
redirect URI after allowing it in the Vault role; disable the vault plugin
|
||||
and a Vault host says it needs it, then enable it again.
|
||||
- Termix-Mobile: the 2.8 termix-id management routes (`/termix-id/me`,
|
||||
`/termix-id/keys`, `/termix-id/ca` and the rest) now 404, only the
|
||||
`/termix-id/u/` resolver redirects. Confirm the mobile app does not call
|
||||
them, and that it still reads a host's metrics settings (`statsConfig` is
|
||||
put back on the host payload by host-metrics) and the renamed Warpgate
|
||||
fields. Once it reads `pluginSettings` and `GET /host/status`, drop
|
||||
host-metrics's `hostPayloadLegacy` hook.
|
||||
- Docs site: point these at the plugins: the OPKSSH config path and redirect
|
||||
URI, the step-ca settings, the Vault redirect URI, the Termix ID resolver
|
||||
URL (`/plugin-api/termix-identity/u/<handle>`, the old one still 308s) and
|
||||
the HTTPS certificate page (`docs.termix.site/features/networking/ssl`,
|
||||
which still describes certbot and the webroot challenge).
|
||||
- Run `bash scripts/upgrade-check.sh` once with Docker running. It upgrades
|
||||
the D4 fixture on real Postgres 16 and MySQL 8. The Postgres half passed
|
||||
against an in-process PGlite server during D4; MySQL has not run yet.
|
||||
- Host settings round trip: filter the Hosts panel by a plugin feature, bulk
|
||||
enable and disable a plugin on a few hosts, export hosts to JSON and import
|
||||
them on another install (plugin settings come across, secrets do not), the
|
||||
raw SQLite export and import, and remote sync of a host with a Vault
|
||||
profile selected (the profile follows it).
|
||||
- Desktop app with a remote server: tunnels on a remote host show their
|
||||
status next to local ones, and the terminal toolbar's metrics bars work
|
||||
for a host on the remote server.
|
||||
- Shared hosts: a remote desktop host shared at "connect" does not show its
|
||||
gateway settings; a shared editor cannot change a Vault host's profile; the
|
||||
host list status dot works for a host someone shared with you.
|
||||
- The PDF preview in the file manager, in the browser and in the desktop app.
|
||||
- The tab right-click menu's share entry on a terminal whose toolbar is
|
||||
hidden, and the terminal toolbar's quick links (tmux monitor, docker,
|
||||
tunnels, host metrics) for a host with those plugins on.
|
||||
- Appearance: switching the interface preset changes the Docker layout and
|
||||
the host metrics column count, a change made in either shows under
|
||||
Appearance with the plugin's name and reverts from there, and a 2.8 user's
|
||||
Docker and host metrics overrides are still applied.
|
||||
- A new host takes the admin "command history for new hosts" setting (a 2.8
|
||||
install that had it off in host defaults keeps it off), and the file
|
||||
manager and terminal are on for a new host.
|
||||
- Settings saves with a bad value are refused with a message: a relative
|
||||
terminal background image path, a step-ca URL that is not https.
|
||||
- Custom `SSL_CERT_PATH` and `SSL_KEY_PATH` in the environment survive a
|
||||
restart and no self-signed certificate is generated over them.
|
||||
- A LAN ntfy or webhook target in the admin allowlist is reached through the
|
||||
configured outbound proxy.
|
||||
- An admin enables a plugin that was off since the upgrade: its 2.8 data is
|
||||
copied in without a restart.
|
||||
- A password user who signs in for the first time after the upgrade keeps
|
||||
their TOTP (the copy runs once their keys unlock).
|
||||
- D1 changes to check by hand: Quick Connect lists Tailscale next to the
|
||||
core auth types and connects with it, and its "Connect to Files" button
|
||||
comes from the file manager; the admin "manage user" panel's Snippets tab
|
||||
(add, edit, delete a snippet for another user); the dashboard's active
|
||||
tunnel counter and its click; the tab bar's file manager button; the
|
||||
secret reference hint and manager in the host and credential editors;
|
||||
Proxmox guest discovery and import (now its own `/plugin-api/proxmox/import`
|
||||
route); the Tailscale device list behind an outbound proxy and against a
|
||||
LAN Headscale; dragging a local file into a host in the desktop app (its
|
||||
transfer URL moved off the dead port 30004); a per-user database export and
|
||||
import that carries file manager bookmarks; the Simple preset hiding plugin
|
||||
rail items except Snippets.
|
||||
|
||||
- Termix-Mobile: after a redirect login (SSO) with the admin setting
|
||||
"second factor after external login" on, the server answers with the
|
||||
second-factor step (pending cookie and `temp_token`) instead of a session.
|
||||
The mobile app has to handle that step; that change is in the mobile repo.
|
||||
- D2 changes to check by hand: a remote desktop session still opens, and a
|
||||
touch mode switch within five minutes reuses its token (after that the app
|
||||
mints a new one); the terminal, file manager and host metrics still connect
|
||||
and sudo still works (they now declare `credentials:read`); Docker and
|
||||
automations connect with a redacted host handed back to core; Admin >
|
||||
Plugins > Permissions lists a plugin's public routes; a disabled plugin's
|
||||
`/plugin-api` URL answers 503; a share-link guest who leaves cannot type.
|
||||
|
||||
## After D3 (needs the repos and keys)
|
||||
|
||||
- Run `termix-plugin keygen`, then pin the public key in
|
||||
`TRUSTED_PLUGIN_KEYS` (`src/backend/plugins/trust.ts`) and save it as
|
||||
`keys/official.pub` in Termix-Registry. Until then the list is empty and
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS=true` blocks every user plugin. Steps are in
|
||||
the Termix-Registry README.
|
||||
- Create the Termix-Registry and Termix-Plugin-Template repos from the local
|
||||
folders next to this repo, and set `TERMIX_PLUGIN_SIGNING_KEY` and
|
||||
`REGISTRY_TOKEN` secrets.
|
||||
- Publish the SDK (`npm run build:sdk && npm publish --workspace
|
||||
@termix/plugin-sdk`), then run `npm install` in Termix-Plugin-Template and
|
||||
commit its `package-lock.json`; its release workflow uses `npm ci`.
|
||||
- Plugins outside this repo cannot use `renderWithApp` or
|
||||
`@termix/plugin-sdk/ui` (types and the test host come from core's source).
|
||||
Ship them in the SDK package before community plugins need UI components.
|
||||
- `npm run test:plugins` on Windows sometimes fails with "Worker exited
|
||||
unexpectedly" in a random plugin (file-manager once, workspaces once); the
|
||||
same suite passes alone and a rerun of the full run passed. Find out why
|
||||
the vitest fork dies (D0).
|
||||
- Check by hand: drop a signed `.tmxplug` into `<DATA_DIR>/plugins/` on a
|
||||
real install, with and without `TERMIX_REQUIRE_SIGNED_PLUGINS=true`.
|
||||
|
||||
## Left for later
|
||||
|
||||
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
|
||||
`global_metrics_interval` and the rest). The upgrade moves leave them in
|
||||
place so a downgrade to 2.8 still works; nothing reads them in 2.9.
|
||||
- Plugin repo split: knip still reports about 150 unused exports inside
|
||||
plugins (constants exported for readability, `tables.ts` exports the CLI
|
||||
reads, test-only helpers). Prune them per plugin when it moves to its own
|
||||
repo; core and the SDK are clean.
|
||||
- Plugin OpenAPI docs: plugins carry `@openapi` comments but
|
||||
`src/backend/utils/swagger.ts` only scans core routes, so
|
||||
`npm run generate:openapi` leaves every `/plugin-api/` route out.
|
||||
- Three backend tests sit next to their source, against the test layout
|
||||
rule: `src/backend/hosts/host-session-status.test.ts`,
|
||||
`jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under
|
||||
`src/backend/tests/hosts/`.
|
||||
- D2 low findings, not fixed: plugin sockets have no Origin check (a
|
||||
SameSite=lax cookie is not sent on a cross-site WebSocket, so only a
|
||||
sibling subdomain could try); a public `:param` path matches every HTTP
|
||||
method and would also match a sibling literal route; `/plugins/public-manifest`
|
||||
shows plugin versions before login; `ctx.process.ensureBinary` hashes any
|
||||
`prebuilt` path on disk; `credentials.resolveHostProtocol` falls back to the
|
||||
SSH password for an RDP login (2.8 behaviour, behind `credentials:read`);
|
||||
a remote desktop display token can be replayed within its five minute TTL
|
||||
(strict single use would break the touch mode switch and reconnect, which
|
||||
reuse it). Revisit with signing and the install UI in 3.0.0.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,18 +1,61 @@
|
||||
# @termix/plugin-sdk
|
||||
<div align="center">
|
||||
|
||||
Everything a [Termix](https://github.com/Termix-SSH/Termix) plugin builds
|
||||
against: the types for `activate(ctx)` and `activate(app)`, table
|
||||
definitions, the manifest schema, test helpers, a vitest preset and the
|
||||
`termix-plugin` CLI.
|
||||
<img src="https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.svg" width="120" height="120" alt="Termix Logo" />
|
||||
|
||||
<h1>Termix Plugin SDK</h1>
|
||||
|
||||
<p>Everything you need to build a Termix plugin</p>
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Overview
|
||||
|
||||
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template). The full guide is at [docs.termix.site/develop](https://docs.termix.site/develop).
|
||||
|
||||
<br />
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install --save-dev @termix/plugin-sdk
|
||||
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
||||
npx termix-plugin test # run the plugin's vitest suite
|
||||
npx termix-plugin validate # check manifest.json and the files it names
|
||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||
npx termix-plugin sign <file.tmxplug> # needs TERMIX_PLUGIN_SIGNING_KEY
|
||||
npm install --save-dev @termix-ssh/plugin-sdk
|
||||
```
|
||||
|
||||
Start from
|
||||
[Termix-Plugin-Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
|
||||
<br />
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
||||
npx termix-plugin patch # apply the plugin's dependency patches (build does this too)
|
||||
npx termix-plugin test # run the plugin's Vitest suite
|
||||
npx termix-plugin validate # check manifest.json and the files it names
|
||||
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
|
||||
npx termix-plugin migrations # write migrations from the table definitions
|
||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
||||
npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
|
||||
npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
|
||||
npx termix-plugin keygen --out <dir> # make a new signing key pair
|
||||
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
|
||||
```
|
||||
|
||||
`dev` needs developer mode on (Settings, General) and an API key from an
|
||||
admin account. It installs the plugin from a file, marked unverified, and
|
||||
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
|
||||
in place of the flags. Reload the page to pick up frontend changes.
|
||||
|
||||
<br />
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
|
||||
@@ -6,6 +6,8 @@ import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
import { openapi } from "./openapi.mjs";
|
||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||
|
||||
const BACKEND_ENTRIES = [
|
||||
"src/backend/index.ts",
|
||||
@@ -96,14 +98,36 @@ export function applyPatches(cwd, pluginId) {
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CSS = path.join(
|
||||
const HOST_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"host",
|
||||
"theme.css",
|
||||
);
|
||||
const THEME_CSS = path.join(HOST_DIR, "theme.css");
|
||||
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
|
||||
|
||||
/**
|
||||
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
||||
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
|
||||
* because the plugin's file loaded later. Only classes core also ships go
|
||||
* there; `utilities: false` keeps the rest in core's own utilities layer.
|
||||
*/
|
||||
export function lowerPluginLayers(css, { utilities = true } = {}) {
|
||||
const layers = utilities ? "properties|utilities" : "properties";
|
||||
return css.replace(
|
||||
new RegExp(`@layer (${layers})(?=[\\s{;,])`, "g"),
|
||||
"@layer termix-plugin-$1",
|
||||
);
|
||||
}
|
||||
|
||||
/** Classes core's CSS has rules for, or null with an SDK built before 1.0.4. */
|
||||
function readCoreClasses() {
|
||||
if (!fs.existsSync(CORE_CLASSES)) return null;
|
||||
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
@@ -112,19 +136,6 @@ const THEME_CSS = path.join(
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
/**
|
||||
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
||||
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
|
||||
* because the plugin's file loaded later.
|
||||
*/
|
||||
export function lowerPluginLayers(css) {
|
||||
return css.replace(
|
||||
/@layer (properties|utilities)(?=[\s{;,])/g,
|
||||
"@layer termix-plugin-$1",
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
@@ -136,10 +147,9 @@ export async function buildTailwind(cwd, outDir) {
|
||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||
].join("\n");
|
||||
const compiler = await compile(input, {
|
||||
base: path.dirname(THEME_CSS),
|
||||
onDependency: () => {},
|
||||
});
|
||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||
const compiler = () =>
|
||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
@@ -151,9 +161,23 @@ export async function buildTailwind(cwd, outDir) {
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const css = lowerPluginLayers(
|
||||
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
|
||||
);
|
||||
const emit = async (candidates, options) =>
|
||||
candidates.length === 0
|
||||
? ""
|
||||
: lowerPluginLayers(
|
||||
optimize((await compiler()).build(candidates), {
|
||||
minify: true,
|
||||
}).code.trim(),
|
||||
options,
|
||||
);
|
||||
const candidates = scanner.scan();
|
||||
const coreClasses = readCoreClasses();
|
||||
const { shared, own } = coreClasses
|
||||
? splitByCoreClasses(candidates, coreClasses)
|
||||
: { shared: candidates, own: [] };
|
||||
const css = [await emit(shared), await emit(own, { utilities: false })]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!css) return;
|
||||
const target = path.join(outDir, "frontend.css");
|
||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
||||
@@ -241,6 +265,7 @@ export async function build({ cwd }) {
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
await openapi({ cwd, quiet: true });
|
||||
|
||||
console.log(`built ${pluginId}`);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { loadSdkModule } from "./validate.mjs";
|
||||
|
||||
/**
|
||||
* Prints one version's notes from CHANGELOG.md, the manifest version unless
|
||||
* --version says otherwise. The release workflow uses it as the release body.
|
||||
*/
|
||||
export async function changelog({ cwd, args = [] }) {
|
||||
const index = args.indexOf("--version");
|
||||
const version =
|
||||
index === -1 ? readManifest(cwd).version : args[index + 1] || null;
|
||||
if (!version) throw new Error("--version needs a value");
|
||||
|
||||
const file = path.join(cwd, "CHANGELOG.md");
|
||||
if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md");
|
||||
|
||||
const { changelogSection } = await loadSdkModule("changelog");
|
||||
const section = changelogSection(fs.readFileSync(file, "utf8"), version);
|
||||
if (!section) {
|
||||
throw new Error(`CHANGELOG.md has no notes for ${version}`);
|
||||
}
|
||||
process.stdout.write(`${section}\n`);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { build } from "./build.mjs";
|
||||
import { pack } from "./pack.mjs";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
|
||||
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
|
||||
const SOURCE_FILES = new Set(["manifest.json", "package.json", "README.md"]);
|
||||
|
||||
function readOption(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (!value) throw new Error(`${name} needs a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** True when a changed path (relative to the plugin) feeds its build. */
|
||||
export function isSourceChange(filename) {
|
||||
if (!filename) return false;
|
||||
const parts = String(filename).replaceAll("\\", "/").split("/");
|
||||
if (parts.length === 1) return SOURCE_FILES.has(parts[0]);
|
||||
return SOURCE_DIRS.has(parts[0]);
|
||||
}
|
||||
|
||||
export function resolveDevOptions(args, env = process.env) {
|
||||
const server = (
|
||||
readOption(args, "--server") ??
|
||||
env.TERMIX_SERVER_URL ??
|
||||
"http://localhost:30001"
|
||||
).replace(/\/+$/, "");
|
||||
const key = readOption(args, "--key") ?? env.TERMIX_API_KEY ?? null;
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"An admin API key is needed. Pass --key tmx_... or set TERMIX_API_KEY.",
|
||||
);
|
||||
}
|
||||
return { server, key, once: args.includes("--once") };
|
||||
}
|
||||
|
||||
const HINTS = {
|
||||
DEVELOPER_MODE_OFF:
|
||||
"Turn on Plugin developer mode in Settings, General, then save again.",
|
||||
SIGNED_ONLY:
|
||||
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
|
||||
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
|
||||
ALREADY_INSTALLED:
|
||||
"Uninstall the copy installed from the registry first, then save again.",
|
||||
};
|
||||
|
||||
async function request(fetchImpl, url, init) {
|
||||
const response = await fetchImpl(url, init);
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { error: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const code = body?.code;
|
||||
const hint = code && HINTS[code] ? `\n ${HINTS[code]}` : "";
|
||||
throw new Error(
|
||||
`${response.status} ${body?.error ?? response.statusText}${hint}`,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Uploads a packed .tmxplug and installs it, agreeing to what it asks for. */
|
||||
export async function pushPlugin(
|
||||
file,
|
||||
{ server, key },
|
||||
fetchImpl = globalThis.fetch,
|
||||
) {
|
||||
const auth = { Authorization: `Bearer ${key}` };
|
||||
const preview = await request(fetchImpl, `${server}/plugins/upload`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/octet-stream" },
|
||||
body: fs.readFileSync(file),
|
||||
});
|
||||
const result = await request(
|
||||
fetchImpl,
|
||||
`${server}/plugins/upload/${preview.token}/install`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ capabilities: preview.capabilities }),
|
||||
},
|
||||
);
|
||||
return { preview, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds, packs and installs the plugin on a running Termix server, then
|
||||
* does it again on every source change. The server needs developer mode on.
|
||||
*/
|
||||
export async function dev({ cwd, args = [] }) {
|
||||
const options = resolveDevOptions(args);
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-dev-"));
|
||||
let lastCapabilities = null;
|
||||
|
||||
const cycle = async () => {
|
||||
const started = Date.now();
|
||||
await build({ cwd });
|
||||
const file = await pack({ cwd, args: ["--out", outDir] });
|
||||
const { preview, result } = await pushPlugin(file, options);
|
||||
fs.rmSync(file, { force: true });
|
||||
const capabilities = preview.capabilities.join(", ") || "none";
|
||||
if (capabilities !== lastCapabilities) {
|
||||
console.log(` capabilities ${capabilities}`);
|
||||
lastCapabilities = capabilities;
|
||||
}
|
||||
console.log(
|
||||
`installed ${result.id}@${result.version} on ${options.server} (${result.state}, ${Date.now() - started} ms)`,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await cycle();
|
||||
} catch (error) {
|
||||
if (options.once) throw error;
|
||||
console.error(error?.message ?? error);
|
||||
}
|
||||
if (options.once) return;
|
||||
|
||||
const { id } = readManifest(cwd);
|
||||
console.log(`watching ${id} for changes, Ctrl+C to stop`);
|
||||
let timer = null;
|
||||
let running = false;
|
||||
let again = false;
|
||||
const fire = async () => {
|
||||
if (running) {
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
await cycle();
|
||||
} catch (error) {
|
||||
console.error(error?.message ?? error);
|
||||
} finally {
|
||||
running = false;
|
||||
if (again) {
|
||||
again = false;
|
||||
void fire();
|
||||
}
|
||||
}
|
||||
};
|
||||
fs.watch(cwd, { recursive: true }, (_event, filename) => {
|
||||
if (!isSourceChange(filename)) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void fire(), 300);
|
||||
});
|
||||
await new Promise(() => {});
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
|
||||
@@ -37,7 +37,7 @@ async function loadDefinitions(cwd) {
|
||||
|
||||
// Emitted inside the plugin rather than in the system temp dir: the SDK
|
||||
// stays external to the bundle, so the output has to sit somewhere node can
|
||||
// resolve @termix/plugin-sdk from.
|
||||
// resolve @termix-ssh/plugin-sdk from.
|
||||
const outdir = fs.mkdtempSync(path.join(cwd, ".termix-tables-"));
|
||||
const outfile = path.join(outdir, "tables.mjs");
|
||||
|
||||
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
|
||||
external: BACKEND_EXTERNALS,
|
||||
});
|
||||
|
||||
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
|
||||
const module = await import(pathToFileURL(outfile).href);
|
||||
const tables = module.tables ?? module.default;
|
||||
if (!Array.isArray(tables)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { loadSdkModule } from "./validate.mjs";
|
||||
|
||||
/**
|
||||
* Writes dist/openapi.json from the @openapi blocks in src/backend. A plugin
|
||||
* with no documented routes gets no file.
|
||||
*/
|
||||
export async function openapi({ cwd, quiet = false }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const backendDir = path.join(cwd, "src", "backend");
|
||||
if (!fs.existsSync(backendDir)) return null;
|
||||
|
||||
const { buildOpenApi, hasOpenApiPaths } = await loadSdkModule("openapi");
|
||||
const spec = await buildOpenApi({
|
||||
title: `${manifest.name ?? pluginId} API`,
|
||||
version: manifest.version ?? "0.0.0",
|
||||
description: manifest.description,
|
||||
files: [path.join(backendDir, "**", "*.{ts,tsx,js,mjs}")],
|
||||
});
|
||||
|
||||
const target = path.join(cwd, "dist", "openapi.json");
|
||||
if (!hasOpenApiPaths(spec)) {
|
||||
fs.rmSync(target, { force: true });
|
||||
if (!quiet) console.log(`${pluginId}: no documented routes`);
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(spec, null, 2)}\n`);
|
||||
if (!quiet) {
|
||||
console.log(
|
||||
`wrote dist/openapi.json (${Object.keys(spec.paths).length} paths)`,
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -7,18 +7,17 @@ import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
* there is one implementation rather than a copy here that drifts.
|
||||
*/
|
||||
async function loadParseManifest() {
|
||||
const entry = new URL("../../dist/manifest.js", import.meta.url);
|
||||
export async function loadSdkModule(name) {
|
||||
const entry = new URL(`../../dist/${name}.js`, import.meta.url);
|
||||
if (!fs.existsSync(fileURLToPath(entry))) {
|
||||
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
||||
}
|
||||
const mod = await import(entry.href);
|
||||
return mod.parseManifest;
|
||||
return import(entry.href);
|
||||
}
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const parseManifest = await loadParseManifest();
|
||||
const { parseManifest } = await loadSdkModule("manifest");
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
@@ -40,6 +39,11 @@ export async function validate({ cwd }) {
|
||||
);
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
problems.push(...(await validateChangelogFile(cwd, raw)));
|
||||
|
||||
for (const warning of docsWarnings(cwd, raw)) {
|
||||
console.warn(` warn ${warning}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -49,6 +53,69 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
"BASE_PATH",
|
||||
"VERSION",
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"PATH",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"ELECTRON_EMBEDDED",
|
||||
]);
|
||||
|
||||
const ENV_READS = [
|
||||
/process\.env\.([A-Z][A-Z0-9_]*)/g,
|
||||
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
|
||||
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||
];
|
||||
|
||||
function walkSources(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkSources(full, out);
|
||||
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs gaps that do not block a release: no docs/index.md, or a process.env
|
||||
* read the manifest does not list in env.
|
||||
*/
|
||||
export function docsWarnings(cwd, raw) {
|
||||
const warnings = [];
|
||||
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
|
||||
warnings.push("docs/index.md is missing, so the plugin has no docs page");
|
||||
}
|
||||
const declared = new Set(
|
||||
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
|
||||
);
|
||||
const missing = new Set();
|
||||
for (const file of walkSources(path.join(cwd, "src"))) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const pattern of ENV_READS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const name = match[1];
|
||||
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of [...missing].sort()) {
|
||||
warnings.push(`reads ${name} but manifest env does not list it`);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json has to agree with the manifest: the same version, and a real
|
||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||
@@ -66,19 +133,37 @@ function validatePackage(cwd, raw) {
|
||||
}
|
||||
|
||||
const sdkRange =
|
||||
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.dependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.devDependencies?.["@termix/plugin-sdk"];
|
||||
pkg.peerDependencies?.["@termix-ssh/plugin-sdk"] ??
|
||||
pkg.dependencies?.["@termix-ssh/plugin-sdk"] ??
|
||||
pkg.devDependencies?.["@termix-ssh/plugin-sdk"];
|
||||
if (!sdkRange) {
|
||||
problems.push("package.json does not depend on @termix/plugin-sdk");
|
||||
problems.push("package.json does not depend on @termix-ssh/plugin-sdk");
|
||||
} else if (sdkRange === "*" || sdkRange === "latest") {
|
||||
problems.push(
|
||||
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
|
||||
`@termix-ssh/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* CHANGELOG.md is optional, but when it is there it has to parse and its
|
||||
* newest release has to match the manifest version.
|
||||
*/
|
||||
async function validateChangelogFile(cwd, raw) {
|
||||
if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
|
||||
return [
|
||||
"CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
|
||||
];
|
||||
}
|
||||
const mdPath = path.join(cwd, "CHANGELOG.md");
|
||||
if (!fs.existsSync(mdPath)) return [];
|
||||
const { validateChangelog } = await loadSdkModule("changelog");
|
||||
return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
|
||||
(problem) => `CHANGELOG.md: ${problem}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A native dependency has to actually be a real dependency of the plugin, or
|
||||
* the build's external declaration points at nothing node_modules can
|
||||
@@ -133,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
|
||||
const present = DIALECTS.filter((dialect) =>
|
||||
fs.existsSync(path.join(root, dialect)),
|
||||
);
|
||||
for (const dialect of DIALECTS) {
|
||||
if (present.length > 0 && !present.includes(dialect)) {
|
||||
problems.push(`migrations/${dialect} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const byDialect = new Map();
|
||||
for (const dialect of present) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* termix-plugin: build, validate, test, pack and sign a Termix plugin.
|
||||
*
|
||||
* Run from a plugin directory (npm run build inside plugins/<id>/ does).
|
||||
* Run from a plugin directory (a plugin repo's npm run build does).
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
@@ -13,17 +13,27 @@ import { test } from "./commands/test.mjs";
|
||||
import { pack } from "./commands/pack.mjs";
|
||||
import { sign, verify, keygen } from "./commands/sign.mjs";
|
||||
import { migrations } from "./commands/migrations.mjs";
|
||||
import { dev } from "./commands/dev.mjs";
|
||||
import { changelog } from "./commands/changelog.mjs";
|
||||
import { openapi } from "./commands/openapi.mjs";
|
||||
|
||||
const COMMANDS = {
|
||||
build,
|
||||
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
|
||||
validate,
|
||||
test,
|
||||
// The tests exercise patched dependencies, so patch first like build does.
|
||||
test: async (options) => {
|
||||
applyPatches(options.cwd, readManifest(options.cwd).id);
|
||||
return test(options);
|
||||
},
|
||||
pack,
|
||||
sign,
|
||||
verify,
|
||||
keygen,
|
||||
migrations,
|
||||
dev,
|
||||
changelog,
|
||||
openapi,
|
||||
};
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
@@ -36,12 +46,17 @@ if (!command || command === "--help" || command === "-h") {
|
||||
" build Bundle the plugin into dist/",
|
||||
" patch Apply the plugin's dependency patches (build does this too)",
|
||||
" validate Check manifest.json and the files it names",
|
||||
" test Run the plugin's vitest suite",
|
||||
" test Apply the patches, then run the plugin's vitest suite",
|
||||
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
|
||||
" sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY <file>",
|
||||
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
||||
" keygen Write a new signing key pair [--out dir]",
|
||||
" migrations Generate migrations from the plugin's table definitions",
|
||||
" openapi Write dist/openapi.json from the @openapi blocks in src/backend",
|
||||
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
|
||||
" dev Build, pack and install on a running server, again on every change",
|
||||
" [--server url] [--key tmx_...] [--once]",
|
||||
" (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(command ? 0 : 1);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The class names a stylesheet has rules for, unescaped, so `.md\:w-44`
|
||||
* reads as the candidate `md:w-44` a plugin writes.
|
||||
*/
|
||||
export function classNamesInCss(css) {
|
||||
const names = new Set();
|
||||
// Declarations hold numbers like 0.25rem, so only selectors are read.
|
||||
const selectors = css.replace(/\{[^{}]*\}/g, "{}");
|
||||
for (const match of selectors.matchAll(
|
||||
/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)/g,
|
||||
)) {
|
||||
names.add(
|
||||
match[1].replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_all, hex, char) =>
|
||||
hex ? String.fromCodePoint(parseInt(hex, 16)) : char,
|
||||
),
|
||||
);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a plugin's candidates by whether core's own CSS already has them.
|
||||
* A plain class core ships keeps the plugin's copy below core's utilities, so
|
||||
* it can never reorder core's rules. Everything else goes in core's utilities
|
||||
* layer: plugin-only classes, and every variant (md:, hover:, ...) even when
|
||||
* core ships it, so `max-h-56 md:max-h-none` is ordered in one build and a
|
||||
* variant still beats core's own base class.
|
||||
*/
|
||||
export function splitByCoreClasses(candidates, coreClasses) {
|
||||
const shared = [];
|
||||
const own = [];
|
||||
for (const candidate of candidates) {
|
||||
const plain = !candidate.includes(":");
|
||||
(plain && coreClasses.has(candidate) ? shared : own).push(candidate);
|
||||
}
|
||||
return { shared, own };
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
*/
|
||||
|
||||
export const BACKEND_EXTERNALS = [
|
||||
"@termix/plugin-sdk",
|
||||
"@termix/plugin-sdk/*",
|
||||
"@termix-ssh/plugin-sdk",
|
||||
"@termix-ssh/plugin-sdk/*",
|
||||
"express",
|
||||
"ssh2",
|
||||
"ws",
|
||||
@@ -41,6 +41,6 @@ export const FRONTEND_EXTERNALS = [
|
||||
"i18next",
|
||||
"react-i18next",
|
||||
"sonner",
|
||||
"@termix/plugin-sdk/frontend",
|
||||
"@termix/plugin-sdk/ui",
|
||||
"@termix-ssh/plugin-sdk/frontend",
|
||||
"@termix-ssh/plugin-sdk/ui",
|
||||
];
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@termix/plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.5",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Termix-SSH/Termix.git",
|
||||
@@ -14,7 +14,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"termix-plugin": "./cli/index.mjs"
|
||||
"termix-plugin": "cli/index.mjs"
|
||||
},
|
||||
"exports": {
|
||||
"./backend": {
|
||||
@@ -49,6 +49,18 @@
|
||||
"types": "./dist/capabilities.d.ts",
|
||||
"default": "./dist/capabilities.js"
|
||||
},
|
||||
"./changelog": {
|
||||
"types": "./dist/changelog.d.ts",
|
||||
"default": "./dist/changelog.js"
|
||||
},
|
||||
"./docs": {
|
||||
"types": "./dist/docs.d.ts",
|
||||
"default": "./dist/docs.js"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./dist/openapi.js"
|
||||
},
|
||||
"./settings": {
|
||||
"types": "./dist/settings.d.ts",
|
||||
"default": "./dist/settings.js"
|
||||
@@ -90,6 +102,7 @@
|
||||
"schema",
|
||||
"tsconfig.plugin.json",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"client.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
@@ -97,6 +110,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@tailwindcss/node": "^4.2.4",
|
||||
"@tailwindcss/oxide": "^4.2.4",
|
||||
"esbuild": "^0.28.1",
|
||||
@@ -134,6 +148,18 @@
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"optional": true
|
||||
},
|
||||
"i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -72,7 +72,39 @@
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A Lucide icon name, or a relative path to an icon.svg in the plugin root."
|
||||
"description": "A Lucide icon name, like \"Play\"."
|
||||
},
|
||||
"video": {
|
||||
"type": "string",
|
||||
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
||||
"description": "A YouTube link shown at the top of the plugin's page."
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"description": "Short lines listing what the plugin does, shown on its page."
|
||||
},
|
||||
"docs": {
|
||||
"type": "string",
|
||||
"pattern": "^https://",
|
||||
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"description": "Environment variables the plugin reads, listed in its docs.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||
"default": { "type": "string" },
|
||||
"required": { "type": "boolean" },
|
||||
"secret": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
|
||||
@@ -339,6 +339,9 @@ export interface PluginRouterOptions {
|
||||
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
|
||||
* ":name" segment matches one segment, and a trailing "/*" matches any
|
||||
* number of further segments, for a proxied page ("/chooser/:id/*").
|
||||
* An entry only opens the route registered with that same path, for the
|
||||
* methods it was registered with, so a literal route beside a ":param"
|
||||
* ("/webhook/config" next to "/webhook/:token") still needs a login.
|
||||
*
|
||||
* For the handful of routes an unauthenticated third party has to reach: an
|
||||
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
||||
@@ -373,7 +376,7 @@ export interface PluginHttp {
|
||||
/**
|
||||
* The public base URL a request came in on: origin plus the install's base
|
||||
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
|
||||
* BASE_PATH and OIDC_FORCE_HTTPS. For building absolute callback URLs.
|
||||
* BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs.
|
||||
*/
|
||||
baseUrl: (req: unknown) => string;
|
||||
}
|
||||
@@ -1368,10 +1371,9 @@ export type PluginVerifiedIdentity =
|
||||
sid?: string | null;
|
||||
};
|
||||
/**
|
||||
* The values a 2.8 install kept on the user row (`oidc_identifier`,
|
||||
* `sso_provider_id`). Only for a plugin that took over a 2.8 login
|
||||
* method: core finds pre-2.9 accounts by `identifier` and keeps writing
|
||||
* both for new users so a downgrade still works.
|
||||
* The identifier a 2.8 install kept for the user. Only for a plugin
|
||||
* that took over a 2.8 login method: core also checks `identifier`
|
||||
* against `allowedUsers`. `providerRowId` is no longer read.
|
||||
*/
|
||||
legacy?: { identifier: string; providerRowId?: number | null };
|
||||
/**
|
||||
@@ -1860,8 +1862,9 @@ export interface PluginBinarySpec {
|
||||
/** Hex SHA-256 the file must have. */
|
||||
sha256: string;
|
||||
/**
|
||||
* Paths checked before downloading, such as a copy baked into the Docker
|
||||
* image. One whose checksum matches is used in place.
|
||||
* Absolute paths checked before downloading, such as a copy baked into the
|
||||
* Docker image. Only a regular file named `name` is considered, and one
|
||||
* whose checksum matches is used in place.
|
||||
*/
|
||||
prebuilt?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
|
||||
*
|
||||
* # Changelog
|
||||
*
|
||||
* ## 1.1.0
|
||||
*
|
||||
* An optional summary paragraph.
|
||||
*
|
||||
* ### Added
|
||||
* - Something new
|
||||
*
|
||||
* ### Fixed
|
||||
* - Something broken
|
||||
*
|
||||
* Releases are listed newest first, and the newest one has to be the
|
||||
* manifest version. Notes go straight under the version they ship in, there
|
||||
* is no Unreleased section.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
|
||||
export const CHANGE_TYPES = [
|
||||
"added",
|
||||
"changed",
|
||||
"fixed",
|
||||
"removed",
|
||||
"deprecated",
|
||||
"security",
|
||||
] as const;
|
||||
|
||||
export type ChangeType = (typeof CHANGE_TYPES)[number];
|
||||
|
||||
export interface ChangelogChange {
|
||||
type: ChangeType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
version: string;
|
||||
date?: string;
|
||||
summary?: string;
|
||||
changes: ChangelogChange[];
|
||||
}
|
||||
|
||||
export interface Changelog {
|
||||
/** Newest first, as written. */
|
||||
releases: ChangelogRelease[];
|
||||
}
|
||||
|
||||
export interface ReleaseNotes {
|
||||
summary?: string;
|
||||
changes: ChangelogChange[];
|
||||
}
|
||||
|
||||
const RELEASE_HEADING =
|
||||
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
|
||||
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const BULLET = /^[-*]\s+(.*)$/;
|
||||
const COMMENT = /^<!--.*-->$/;
|
||||
|
||||
/**
|
||||
* Parses the body under one release heading: an optional summary, then
|
||||
* `### Added` style sections of bullets. Indented lines continue the bullet
|
||||
* above them.
|
||||
*/
|
||||
export function parseReleaseNotes(
|
||||
body: string,
|
||||
where = "release",
|
||||
): ReleaseNotes & { problems: string[] } {
|
||||
const problems: string[] = [];
|
||||
const summary: string[] = [];
|
||||
const changes: ChangelogChange[] = [];
|
||||
let section: ChangeType | null = null;
|
||||
let current: ChangelogChange | null = null;
|
||||
let fence = false;
|
||||
|
||||
for (const raw of body.replace(/\r/g, "").split("\n")) {
|
||||
const line = raw.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) fence = !fence;
|
||||
|
||||
if (!fence && trimmed.startsWith("### ")) {
|
||||
const name = trimmed.slice(4).trim().toLowerCase();
|
||||
if ((CHANGE_TYPES as readonly string[]).includes(name)) {
|
||||
section = name as ChangeType;
|
||||
} else {
|
||||
problems.push(
|
||||
`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`,
|
||||
);
|
||||
section = null;
|
||||
}
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!section) {
|
||||
if (trimmed || summary.length > 0) summary.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed || COMMENT.test(trimmed)) continue;
|
||||
const bullet = fence ? null : BULLET.exec(line);
|
||||
if (bullet) {
|
||||
current = { type: section, text: bullet[1].trim() };
|
||||
changes.push(current);
|
||||
} else if (current && /^\s/.test(line)) {
|
||||
current.text += `\n${trimmed}`;
|
||||
} else {
|
||||
problems.push(
|
||||
`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
if (!change.text) {
|
||||
problems.push(`${where} has an empty list item`);
|
||||
}
|
||||
}
|
||||
|
||||
const text = summary.join("\n").trim();
|
||||
return { ...(text ? { summary: text } : {}), changes, problems };
|
||||
}
|
||||
|
||||
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
|
||||
export function parseChangelog(markdown: string): {
|
||||
changelog: Changelog;
|
||||
problems: string[];
|
||||
} {
|
||||
const problems: string[] = [];
|
||||
const changelog: Changelog = { releases: [] };
|
||||
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||
|
||||
type Block = { heading: string; line: number; body: string[] };
|
||||
const blocks: Block[] = [];
|
||||
let fence = false;
|
||||
lines.forEach((line, i) => {
|
||||
if (line.trim().startsWith("```")) fence = !fence;
|
||||
if (!fence && /^##\s/.test(line)) {
|
||||
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
|
||||
} else if (blocks.length > 0) {
|
||||
blocks[blocks.length - 1].body.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
blocks.forEach((block) => {
|
||||
if (UNRELEASED_HEADING.test(block.heading)) {
|
||||
problems.push(
|
||||
`line ${block.line}: drop "## Unreleased", notes go under the version they ship in`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const match = RELEASE_HEADING.exec(block.heading);
|
||||
if (!match) {
|
||||
problems.push(
|
||||
`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const [, version, date] = match;
|
||||
if (date !== undefined && !DATE.test(date)) {
|
||||
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
|
||||
}
|
||||
if (seen.has(version)) problems.push(`${version} is listed twice`);
|
||||
seen.add(version);
|
||||
|
||||
const notes = parseReleaseNotes(block.body.join("\n"), version);
|
||||
problems.push(...notes.problems);
|
||||
if (notes.changes.length === 0) {
|
||||
problems.push(`${version} has no changes listed`);
|
||||
}
|
||||
changelog.releases.push({
|
||||
version,
|
||||
...(date && DATE.test(date) ? { date } : {}),
|
||||
...(notes.summary ? { summary: notes.summary } : {}),
|
||||
changes: notes.changes,
|
||||
});
|
||||
});
|
||||
|
||||
const versions = changelog.releases.map((r) => r.version);
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
if (semver.lt(versions[i - 1], versions[i])) {
|
||||
problems.push("releases must be listed newest first");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { changelog, problems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
||||
* be that version, so a release never ships without its notes. A beta like
|
||||
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
|
||||
* to be 1.1.0 or older.
|
||||
*/
|
||||
export function validateChangelog(
|
||||
markdown: string,
|
||||
manifestVersion?: string,
|
||||
): string[] {
|
||||
const { changelog, problems } = parseChangelog(markdown);
|
||||
if (changelog.releases.length === 0) {
|
||||
problems.push('no releases found, add a "## 1.0.0" heading');
|
||||
return problems;
|
||||
}
|
||||
const newest = changelog.releases[0].version;
|
||||
const base = manifestVersion
|
||||
? semver.valid(semver.coerce(manifestVersion))
|
||||
: null;
|
||||
const betaOk =
|
||||
!!manifestVersion &&
|
||||
!!semver.prerelease(manifestVersion) &&
|
||||
!!base &&
|
||||
!!semver.valid(newest) &&
|
||||
semver.lte(newest, base);
|
||||
if (manifestVersion && newest !== manifestVersion && !betaOk) {
|
||||
problems.push(
|
||||
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Markdown under one version's heading, as written, for a release body.
|
||||
* Null when the version is not in the file.
|
||||
*/
|
||||
export function changelogSection(
|
||||
markdown: string,
|
||||
version: string,
|
||||
): string | null {
|
||||
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||
let start = -1;
|
||||
let fence = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim().startsWith("```")) fence = !fence;
|
||||
if (fence || !/^##\s/.test(line)) continue;
|
||||
if (start >= 0) {
|
||||
return lines.slice(start, i).join("\n").trim();
|
||||
}
|
||||
const match = RELEASE_HEADING.exec(line.trim());
|
||||
if (match && match[1] === version) start = i + 1;
|
||||
}
|
||||
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
||||
}
|
||||
|
||||
function title(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
|
||||
export const id = () =>
|
||||
builder({ type: "id", primaryKey: true, notNull: true });
|
||||
|
||||
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
|
||||
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
|
||||
export const text = () => builder({ type: "text" });
|
||||
|
||||
/** Bounded string. Indexable on every engine. */
|
||||
|
||||
@@ -80,11 +80,13 @@ function columnType(
|
||||
// though it was declared unbounded. defineTable refuses to index a
|
||||
// text/json column, so in practice this only catches a primary key.
|
||||
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
|
||||
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
|
||||
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
/** Column types that are TEXT on MySQL when nothing keys them. */
|
||||
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
|
||||
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
|
||||
"text",
|
||||
"json",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Docs links and env var entries from a manifest. Kept apart from manifest.ts
|
||||
* so the browser can use them without pulling in semver.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A docs link, or null for anything that is not an https URL. Core only ever
|
||||
* opens it in a new tab, but a javascript: or file: link should never get there.
|
||||
*/
|
||||
export function pluginDocsUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "https:") return null;
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Joins a page and anchor onto a plugin's docs URL. */
|
||||
export function pluginDocsPage(
|
||||
base: string,
|
||||
page?: string,
|
||||
anchor?: string,
|
||||
): string {
|
||||
const clean = (page ?? "").replace(/^\/+|\/+$/g, "");
|
||||
const url = clean ? `${base}/${clean}` : base;
|
||||
return anchor ? `${url}#${anchor.replace(/^#/, "")}` : url;
|
||||
}
|
||||
|
||||
export const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
export const MAX_ENV_DESCRIPTION_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* An environment variable a plugin reads. Only shown in docs and on the
|
||||
* plugin's page, so people know what they can set.
|
||||
*/
|
||||
export interface PluginEnvVar {
|
||||
name: string;
|
||||
description: string;
|
||||
/** The value used when it is not set, as written in a compose file. */
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
/** Holds a password or key. */
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
/** The usable env entries of a manifest. Bad ones are dropped. */
|
||||
export function pluginEnvVars(value: unknown): PluginEnvVar[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(v): v is PluginEnvVar =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof v.name === "string" &&
|
||||
ENV_NAME_PATTERN.test(v.name) &&
|
||||
typeof v.description === "string",
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,21 @@ export interface ShellApi {
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
closeRailView: (id: string) => void;
|
||||
/**
|
||||
* Asks a yes or no question over the main area, for code that runs outside
|
||||
* a panel or tab (a keybinding, a toolbar action). Inside one, prefer
|
||||
* useConfirm from @termix-ssh/plugin-sdk/ui, which asks over that surface.
|
||||
*/
|
||||
confirm?: (options: ConfirmRequest) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Red confirm button. Defaults to true. */
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsApi extends Pick<
|
||||
@@ -260,6 +275,11 @@ export interface RailItemContribution {
|
||||
/** Desktop app only. */
|
||||
electronOnly?: boolean;
|
||||
separatorAfter?: boolean;
|
||||
/**
|
||||
* Which band of the rail it sits in: things you keep (objects), things you
|
||||
* do (tools) or the instance itself (system). Default "tools".
|
||||
*/
|
||||
group?: "objects" | "tools" | "system";
|
||||
/** Hidden without being unregistered, e.g. while a feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/**
|
||||
@@ -293,8 +313,11 @@ export interface PanelProps {
|
||||
/** Whether the panel is the one currently shown. */
|
||||
active: boolean;
|
||||
shell: ShellApi;
|
||||
/** Tells the shell the panel is editing, which widens the sidebar. */
|
||||
setEditing: (editing: boolean) => void;
|
||||
/**
|
||||
* Tells the shell the panel is editing, which widens the sidebar. "wide"
|
||||
* widens it further for tables. InlineView does this on its own.
|
||||
*/
|
||||
setEditing: (editing: boolean | "wide") => void;
|
||||
/** Type of the focused tab, if any. */
|
||||
activeTabType?: string;
|
||||
/** Where the panel is rendered. */
|
||||
@@ -323,6 +346,8 @@ export interface TabHandle {
|
||||
/** Repaint only. The shell calls it every time the tab becomes active. */
|
||||
refresh?: () => void;
|
||||
notifyResize?: () => void;
|
||||
/** Asked before the tab closes; resolve false to keep it open. */
|
||||
confirmClose?: () => boolean | Promise<boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -629,6 +654,11 @@ export interface DashboardCardContribution {
|
||||
defaultHeight?: number;
|
||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||
defaultPanel?: "main" | "side";
|
||||
/**
|
||||
* "framed" draws a titled header over the card, "bare" lets it run edge to
|
||||
* edge, for a strip of numbers. Defaults to "framed".
|
||||
*/
|
||||
frame?: "framed" | "bare";
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
@@ -664,7 +694,11 @@ export interface ActionSlotDefinition {
|
||||
export interface SlotContribution {
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
/**
|
||||
* Secondary text, for slots that show one. The onboarding.* slots that
|
||||
* used it are deprecated: use registerOnboardingStep. The feature and
|
||||
* workflow card slots are no longer shown.
|
||||
*/
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
kind?: "button" | "component" | string;
|
||||
@@ -675,6 +709,65 @@ export interface SlotContribution {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type OnboardingMode = "full" | "partial" | "rerun";
|
||||
|
||||
/**
|
||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||
* appearance steps, for things that need doing before the feature works
|
||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||
* that, "security" after the account security step. Onboarding is for
|
||||
* choices, not for explaining features.
|
||||
*/
|
||||
export type OnboardingSection = "setup" | "explore" | "security";
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
/**
|
||||
* "full" is a new user's first run, "partial" shows only steps they have
|
||||
* not seen yet (a plugin installed later, a step whose version went up),
|
||||
* "rerun" is the user running setup again from settings.
|
||||
*/
|
||||
mode: OnboardingMode;
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
/** Blocks or allows Next. Steps can continue by default. */
|
||||
setCanContinue: (ok: boolean) => void;
|
||||
/**
|
||||
* Runs when Next is pressed, before moving on. Return false to stay on the
|
||||
* step (to show an error, say). Pass null to clear it.
|
||||
*/
|
||||
setBeforeNext: (fn: (() => boolean | Promise<boolean>) | null) => void;
|
||||
/**
|
||||
* False on a first run, which shows before the app shell exists: app.tabs
|
||||
* and the shell callbacks do nothing until it is true.
|
||||
*/
|
||||
shellReady: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStepContribution {
|
||||
/** Unique within the plugin. Lowercase letters, digits and dashes. */
|
||||
id: string;
|
||||
/**
|
||||
* Starts at 1. Raise it when the step changes enough that people who saw
|
||||
* it should see it again; they get just this step, not all of onboarding.
|
||||
*/
|
||||
version?: number;
|
||||
titleKey: string;
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
component: ComponentType<OnboardingStepProps>;
|
||||
/** "admin" shows it only to users who can manage plugins. */
|
||||
audience?: "all" | "admin";
|
||||
/** A permission the user must hold, like registerAction's. */
|
||||
permission?: string;
|
||||
section?: OnboardingSection;
|
||||
order?: number;
|
||||
when?: (context: {
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
mode: OnboardingMode;
|
||||
}) => boolean;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorProps {
|
||||
form: Record<string, unknown>;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
@@ -795,6 +888,16 @@ export interface DesktopApi {
|
||||
onRemoteServerChange: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
/** Links into this plugin's docs, from the manifest's docs field. */
|
||||
export interface DocsApi {
|
||||
/** The docs link, or null when the manifest has none. */
|
||||
readonly url: string | null;
|
||||
/** A page of the docs ("setup"), with an optional anchor. */
|
||||
page: (page?: string, anchor?: string) => string | null;
|
||||
/** Opens a page in a new tab. Does nothing without a docs link. */
|
||||
open: (page?: string, anchor?: string) => void;
|
||||
}
|
||||
|
||||
export interface TermixAppInfo {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -864,10 +967,17 @@ export interface TermixApp extends TermixAppInfo {
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
) => Disposer;
|
||||
/**
|
||||
* Adds a step to onboarding. New users see it in their first run; people
|
||||
* who already finished onboarding see just this step the next time they
|
||||
* open Termix (or right away, if they install the plugin mid-session).
|
||||
* Needs plugin API 1.1.
|
||||
*/
|
||||
registerOnboardingStep: (step: OnboardingStepContribution) => Disposer;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
/**
|
||||
* Offers a component to other plugins and to core by id, rendered where
|
||||
* they choose with `PluginComponent` from @termix/plugin-sdk/ui or
|
||||
* they choose with `PluginComponent` from @termix-ssh/plugin-sdk/ui or
|
||||
* `usePluginComponent`. The id should start with a name the plugin owns
|
||||
* ("terminal.view"). The props are the owner's contract; document them.
|
||||
*/
|
||||
@@ -909,8 +1019,16 @@ export interface TermixApp extends TermixAppInfo {
|
||||
options?: { origin?: unknown },
|
||||
) => Promise<PluginWsTarget | null>;
|
||||
tabs: TabsApi;
|
||||
/**
|
||||
* Asks a yes or no question over the main area. For code that runs outside
|
||||
* a panel or tab; inside one, useConfirm from @termix-ssh/plugin-sdk/ui asks
|
||||
* over that surface instead.
|
||||
*/
|
||||
confirm: (options: ConfirmRequest) => Promise<boolean>;
|
||||
/** The desktop app, when the frontend runs in it. */
|
||||
desktop: DesktopApi;
|
||||
/** This plugin's docs. Needs plugin API 1.2. */
|
||||
docs: DocsApi;
|
||||
/**
|
||||
* Fires after this plugin's settings are saved from the settings screen or
|
||||
* the host editor. Disposed automatically.
|
||||
@@ -985,6 +1103,11 @@ export interface SshAuthTypeInfo {
|
||||
*/
|
||||
export interface PluginHostBridge {
|
||||
usePluginId: () => string;
|
||||
useDocsUrl: (
|
||||
pluginId: string,
|
||||
page?: string,
|
||||
anchor?: string,
|
||||
) => string | null;
|
||||
useTranslation: (pluginId: string) => {
|
||||
t: TranslateFn;
|
||||
language: string;
|
||||
@@ -1154,7 +1277,7 @@ let host: PluginHostBridge | null = null;
|
||||
export function __setPluginHost(bridge: PluginHostBridge | null): void {
|
||||
if (host && bridge !== host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: the plugin host is already set",
|
||||
"@termix-ssh/plugin-sdk/frontend: the plugin host is already set",
|
||||
);
|
||||
}
|
||||
host = bridge;
|
||||
@@ -1173,7 +1296,7 @@ function requireHost(): PluginHostBridge {
|
||||
if (!host && fallback) return fallback;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
"@termix-ssh/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
);
|
||||
}
|
||||
return host;
|
||||
@@ -1184,6 +1307,15 @@ export function usePluginId(): string {
|
||||
return requireHost().usePluginId();
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of this plugin's docs, or null when the manifest has no docs link.
|
||||
* Pass it to PanelShell's docs prop or use DocsLink. Needs plugin API 1.2.
|
||||
*/
|
||||
export function useDocsUrl(page?: string, anchor?: string): string | null {
|
||||
const bridge = requireHost();
|
||||
return bridge.useDocsUrl(bridge.usePluginId(), page, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation bound to this plugin's namespace. Keys the plugin does not
|
||||
* define fall back to core's shared strings (common.*, hosts.*).
|
||||
@@ -1333,6 +1465,31 @@ export function usePluginUiPreferences<
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid or list and row density for one of this plugin's views, kept in its
|
||||
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
|
||||
* keys in contributes.uiPresets so each preset level can pick a default.
|
||||
*/
|
||||
export function usePluginPanelView(prefix = ""): {
|
||||
view: "grid" | "list";
|
||||
density: "comfortable" | "compact";
|
||||
compact: boolean;
|
||||
setView: (view: "grid" | "list") => void;
|
||||
setDensity: (density: "comfortable" | "compact") => void;
|
||||
} {
|
||||
const { values, set } = usePluginUiPreferences();
|
||||
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
||||
const density =
|
||||
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
|
||||
return {
|
||||
view,
|
||||
density,
|
||||
compact: density === "compact",
|
||||
setView: (next) => set(`${prefix}viewMode`, next),
|
||||
setDensity: (next) => set(`${prefix}density`, next),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function execCommand(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
|
||||
@@ -12,6 +12,21 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
import {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
pluginDocsUrl,
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
export {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
pluginDocsPage,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
/**
|
||||
* The plugin API this build implements. A minor bump adds to the API and
|
||||
@@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js";
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.0.0";
|
||||
export const PLUGIN_API_VERSION = "1.2.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -49,6 +64,63 @@ export function isTermixCompatible(
|
||||
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
||||
}
|
||||
|
||||
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"m.youtube.com",
|
||||
"youtu.be",
|
||||
"youtube-nocookie.com",
|
||||
"www.youtube-nocookie.com",
|
||||
]);
|
||||
|
||||
export const MAX_FEATURES = 20;
|
||||
export const MAX_FEATURE_LENGTH = 160;
|
||||
|
||||
/**
|
||||
* The usable feature lines of a manifest or registry entry. Anything that is
|
||||
* not a short non-empty string is dropped, so an index cannot flood the page.
|
||||
*/
|
||||
export function pluginFeatures(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(f): f is string =>
|
||||
typeof f === "string" &&
|
||||
f.trim().length > 0 &&
|
||||
f.length <= MAX_FEATURE_LENGTH,
|
||||
)
|
||||
.map((f) => f.trim())
|
||||
.slice(0, MAX_FEATURES);
|
||||
}
|
||||
|
||||
/**
|
||||
* The video id of a YouTube link, or null for anything else. Only the id is
|
||||
* ever used, so a manifest can never point the embed at another site.
|
||||
*/
|
||||
export function youtubeVideoId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password) return null;
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!YOUTUBE_HOSTS.has(host)) return null;
|
||||
|
||||
let id: string | null | undefined;
|
||||
if (host === "youtu.be") {
|
||||
id = url.pathname.slice(1);
|
||||
} else if (url.pathname === "/watch") {
|
||||
id = url.searchParams.get("v");
|
||||
} else {
|
||||
id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1];
|
||||
}
|
||||
return id && YOUTUBE_ID.test(id) ? id : null;
|
||||
}
|
||||
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"Terminal",
|
||||
"Files & Transfer",
|
||||
@@ -81,6 +153,7 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
"termix",
|
||||
"plugin",
|
||||
"plugins",
|
||||
"onboarding",
|
||||
"users",
|
||||
"system",
|
||||
"sdk",
|
||||
@@ -275,11 +348,6 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Deprecated and ignored: host defaults cover every host field now. Still
|
||||
* accepted so an older manifest loads. Removed in 3.0.0.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: whether the field can have a host default (server,
|
||||
* user or folder) that hosts follow until they set their own. On by
|
||||
@@ -439,7 +507,8 @@ export interface PluginProtocolContribution {
|
||||
defaultPort?: number;
|
||||
/**
|
||||
* What the owner's login falls back to from the host's own SSH login
|
||||
* when the protocol login leaves it empty.
|
||||
* when the protocol login leaves it empty. The password only falls back
|
||||
* while the host signs in to SSH with a password.
|
||||
*/
|
||||
hostLoginFallback?: Array<"username" | "password">;
|
||||
}
|
||||
@@ -543,6 +612,14 @@ export interface PluginManifest {
|
||||
repository?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
/** A YouTube link shown at the top of the plugin's page. */
|
||||
video?: string;
|
||||
/** Short lines listing what the plugin does, shown on its page. */
|
||||
features?: string[];
|
||||
/** Where the plugin's docs live. https only. */
|
||||
docs?: string;
|
||||
/** Environment variables the plugin reads. */
|
||||
env?: PluginEnvVar[];
|
||||
engine: PluginEngine;
|
||||
/** Catalog capability ids. */
|
||||
capabilities: string[];
|
||||
@@ -594,6 +671,10 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
...REQUIRED_TOP_LEVEL,
|
||||
"repository",
|
||||
"icon",
|
||||
"video",
|
||||
"features",
|
||||
"docs",
|
||||
"env",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"provides",
|
||||
@@ -644,7 +725,6 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"group",
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
@@ -751,6 +831,18 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
|
||||
if ("repository" in m) requireString(m.repository, "repository", errors);
|
||||
if ("icon" in m) requireString(m.icon, "icon", errors);
|
||||
if ("video" in m && youtubeVideoId(m.video) === null) {
|
||||
errors.push(
|
||||
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
|
||||
);
|
||||
}
|
||||
validateFeatures(m.features, errors);
|
||||
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
|
||||
errors.push(
|
||||
`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`,
|
||||
);
|
||||
}
|
||||
validateEnv(m.env, errors);
|
||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||
if (field in m && requireString(m[field], field, errors)) {
|
||||
requireRelativePath(m[field] as string, field, errors);
|
||||
@@ -786,6 +878,76 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFeatures(features: unknown, errors: string[]): void {
|
||||
if (features === undefined) return;
|
||||
if (!Array.isArray(features)) {
|
||||
errors.push('Field "features" must be an array of strings');
|
||||
return;
|
||||
}
|
||||
if (features.length > MAX_FEATURES) {
|
||||
errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
|
||||
}
|
||||
features.forEach((feature, i) => {
|
||||
if (
|
||||
typeof feature !== "string" ||
|
||||
feature.trim().length === 0 ||
|
||||
feature.length > MAX_FEATURE_LENGTH
|
||||
) {
|
||||
errors.push(
|
||||
`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateEnv(env: unknown, errors: string[]): void {
|
||||
if (env === undefined) return;
|
||||
if (!Array.isArray(env)) {
|
||||
errors.push('Field "env" must be an array');
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
env.forEach((entry, i) => {
|
||||
const where = `env[${i}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
entry,
|
||||
["name", "description", "default", "required", "secret"],
|
||||
where,
|
||||
errors,
|
||||
);
|
||||
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
|
||||
errors.push(
|
||||
`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`,
|
||||
);
|
||||
} else if (seen.has(entry.name)) {
|
||||
errors.push(`Field "${where}.name" repeats ${entry.name}`);
|
||||
} else {
|
||||
seen.add(entry.name);
|
||||
}
|
||||
if (
|
||||
typeof entry.description !== "string" ||
|
||||
entry.description.trim().length === 0 ||
|
||||
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH
|
||||
) {
|
||||
errors.push(
|
||||
`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
if ("default" in entry && typeof entry.default !== "string") {
|
||||
errors.push(`Field "${where}.default" must be a string`);
|
||||
}
|
||||
for (const flag of ["required", "secret"] as const) {
|
||||
if (flag in entry && typeof entry[flag] !== "boolean") {
|
||||
errors.push(`Field "${where}.${flag}" must be true or false`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateAuthor(author: unknown, errors: string[]): void {
|
||||
if (author === undefined) return;
|
||||
if (!isPlainObject(author)) {
|
||||
@@ -1614,9 +1776,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
|
||||
errors.push(`${fieldAt}.defaultFrom must be a string`);
|
||||
}
|
||||
for (const flag of ["defaultable", "personal"]) {
|
||||
if (flag in raw && typeof raw[flag] !== "boolean") {
|
||||
errors.push(`${fieldAt}.${flag} must be a boolean`);
|
||||
@@ -1670,7 +1829,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of [
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
|
||||
* every plugin use this, so the docs site renders them all the same way.
|
||||
*/
|
||||
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
|
||||
export interface OpenApiTag {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BuildOpenApiOptions {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
/** Source files or globs holding @openapi blocks. */
|
||||
files: string[];
|
||||
tags?: OpenApiTag[];
|
||||
}
|
||||
|
||||
export type OpenApiSpec = Record<string, unknown> & {
|
||||
paths?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** How every Termix route is signed in to. */
|
||||
export const TERMIX_SECURITY_SCHEMES = {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
description:
|
||||
"Session token sent in the Authorization header as Bearer and the token.",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "jwt",
|
||||
description:
|
||||
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
||||
},
|
||||
apiKeyAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "API key starting with tmx_",
|
||||
description:
|
||||
"API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function buildOpenApi(
|
||||
options: BuildOpenApiOptions,
|
||||
): Promise<OpenApiSpec> {
|
||||
const spec = (await swaggerJSDoc({
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: options.title,
|
||||
version: options.version,
|
||||
...(options.description ? { description: options.description } : {}),
|
||||
},
|
||||
servers: [{ url: "/", description: "Your Termix server" }],
|
||||
components: {
|
||||
securitySchemes: TERMIX_SECURITY_SCHEMES,
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
|
||||
...(options.tags?.length ? { tags: options.tags } : {}),
|
||||
},
|
||||
apis: options.files.map((f) => f.replace(/\\/g, "/")),
|
||||
failOnErrors: true,
|
||||
} as Parameters<typeof swaggerJSDoc>[0])) as OpenApiSpec;
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** Whether a spec documents any route. */
|
||||
export function hasOpenApiPaths(spec: OpenApiSpec): boolean {
|
||||
return Object.keys(spec.paths ?? {}).length > 0;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* real runtime does. createFakeContext stays for tests that only need a
|
||||
* context-shaped object and do not care about the gates.
|
||||
*
|
||||
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
|
||||
* The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -2080,7 +2080,7 @@ export async function createTestDb(
|
||||
* so a test sees exactly what the shell would: which rail items, tabs, host
|
||||
* actions and cards it registered, and each of those rendered with the SDK
|
||||
* hooks working. It is implemented by the Termix host, which is where the
|
||||
* registries live; the vitest preset points "@termix/plugin-host/testing" at
|
||||
* registries live; the vitest preset points "@termix-ssh/plugin-host/testing" at
|
||||
* core's implementation.
|
||||
*/
|
||||
export interface RenderWithAppOptions {
|
||||
@@ -2139,6 +2139,8 @@ export interface RenderedPluginApp {
|
||||
paletteGroups: () => string[];
|
||||
keybindingActions: () => string[];
|
||||
keybindingDefaults: () => string[];
|
||||
/** Onboarding step ids, without the plugin prefix. */
|
||||
onboardingSteps: () => string[];
|
||||
};
|
||||
/** A registered palette group's items, as the palette loads them on open. */
|
||||
loadPaletteGroup: (
|
||||
@@ -2160,6 +2162,19 @@ export interface RenderedPluginApp {
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
/**
|
||||
* Renders an onboarding step with OnboardingStepProps for a first run,
|
||||
* overridable. Read what the step set with the returned `canContinue()`
|
||||
* and `next()` (runs its beforeNext, true when it has none).
|
||||
*/
|
||||
renderOnboardingStep: (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => {
|
||||
element: HTMLElement;
|
||||
canContinue: () => boolean;
|
||||
next: () => Promise<boolean>;
|
||||
};
|
||||
/** Renders one component of a contributed extension, "view" by default. */
|
||||
renderExtension: (
|
||||
pointId: string,
|
||||
@@ -2205,7 +2220,7 @@ interface PluginTestHost {
|
||||
) => Promise<RenderedPluginApp>;
|
||||
}
|
||||
|
||||
const TEST_HOST = "@termix/plugin-host/testing";
|
||||
const TEST_HOST = "@termix-ssh/plugin-host/testing";
|
||||
|
||||
export async function renderWithApp(
|
||||
plugin: FrontendPluginModule,
|
||||
|
||||
@@ -10,6 +10,15 @@ import { installSafePorts } from "./safe-ports.js";
|
||||
|
||||
installSafePorts();
|
||||
|
||||
// Radix measures some controls (Checkbox, Switch) and jsdom has no observer.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
@@ -76,6 +85,7 @@ const offline = () =>
|
||||
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
||||
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
||||
usePluginId: () => "test-plugin",
|
||||
useDocsUrl: () => null,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
language: "en",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* A plugin's vitest.config.ts is one line:
|
||||
*
|
||||
* import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
* import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
|
||||
* export default pluginVitestConfig(import.meta.url);
|
||||
*
|
||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||
@@ -56,21 +56,21 @@ export function pluginVitestConfig(
|
||||
const alias: Record<string, string> = {
|
||||
...(repoRoot
|
||||
? {
|
||||
"@termix/plugin-sdk/frontend": path.join(
|
||||
"@termix-ssh/plugin-sdk/frontend": path.join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"src",
|
||||
"frontend.ts",
|
||||
),
|
||||
"@termix/plugin-sdk/ui": path.join(
|
||||
"@termix-ssh/plugin-sdk/ui": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"sdk-ui.ts",
|
||||
),
|
||||
"@termix/plugin-host/testing": path.join(
|
||||
"@termix-ssh/plugin-host/testing": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
@@ -85,18 +85,23 @@ export function pluginVitestConfig(
|
||||
: {
|
||||
// Outside a Termix checkout: the UI kit resolves through the
|
||||
// package, and the test host is the copy the SDK ships.
|
||||
"@termix/plugin-host/testing": fileURLToPath(
|
||||
"@termix-ssh/plugin-host/testing": fileURLToPath(
|
||||
new URL("./host/testing-host.js", import.meta.url),
|
||||
),
|
||||
}),
|
||||
...options.alias,
|
||||
};
|
||||
|
||||
// Forks workers on Windows abort now and then with "Worker exited
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
@@ -104,6 +109,7 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
environment: "node",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
include: ["tests/backend/**/*.test.ts"],
|
||||
@@ -115,12 +121,13 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
environment: "jsdom",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
// Run the SDK through Vite rather than plain Node, so its import
|
||||
// of the test host sees the alias above and the plugin and the
|
||||
// host share one copy of @termix/plugin-sdk/frontend.
|
||||
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
|
||||
// host share one copy of @termix-ssh/plugin-sdk/frontend.
|
||||
server: { deps: { inline: [/@termix-ssh\/plugin-sdk/] } },
|
||||
include: ["tests/frontend/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
@@ -13,6 +14,7 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||
BarSeriesItem (type): BarSeriesItem
|
||||
@@ -27,17 +29,25 @@ CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttribu
|
||||
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
|
||||
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardMasonry (value): ({ columns, stack, className, children, }: { columns: number; stack?: boolean; className?: string; children: ReactNode; }) => Element
|
||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
|
||||
ConfirmOptions (type): ConfirmOptions
|
||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||
ConnectionGateController (type): ConnectionGateController
|
||||
ConnectionGateProps (type): ConnectionGateProps
|
||||
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, className, }: ConnectionLogPanelProps) => Element
|
||||
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
|
||||
ConnectionOrigin (type): ConnectionOrigin
|
||||
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
|
||||
ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, disconnectedMessage, onClose, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
|
||||
ConnectionStage (type): ConnectionStage
|
||||
ConnectionStatus (type): ConnectionStatus
|
||||
ConnectionUnavailable (type): ConnectionUnavailable
|
||||
CustomKeybinding (type): CustomKeybinding
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
@@ -48,6 +58,8 @@ DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttribu
|
||||
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
|
||||
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||
DocsLinkProps (type): DocsLinkProps
|
||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
@@ -63,20 +75,28 @@ DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
|
||||
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
EditingWidth (type): EditingWidth
|
||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||
FOLDER_COLORS (value): string[]
|
||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
GridColSpan (type): GridColSpan
|
||||
GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
@@ -87,14 +107,31 @@ KeybindingActionType (type): string
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
ListRowTone (type): ListRowTone
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
ManagerCardError (type): ManagerCardError
|
||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
||||
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
|
||||
PanelViewMode (type): PanelViewMode
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||
@@ -106,14 +143,18 @@ PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLB
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
|
||||
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
|
||||
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
|
||||
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
|
||||
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectField (value): <T extends string>({ label, value, onChange, options, hint, error, disabled, className, placeholder, }: { label: string; value: T; onChange: (value: T) => void; options: readonly { value: T; label: string; }[]; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; placeholder?: string; }) => Element
|
||||
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
|
||||
@@ -123,7 +164,7 @@ SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAtt
|
||||
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element
|
||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||
@@ -137,8 +178,16 @@ Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderP
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
SparklineProps (type): SparklineProps
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||
TabStripItem (type): TabStripItem
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
@@ -146,7 +195,9 @@ TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipC
|
||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
UptimeInfo (type): UptimeInfo
|
||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||
@@ -177,12 +228,16 @@ resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||
useAppTheme (value): () => ThemeProviderState
|
||||
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
Reference in new issue
Block a user