Compare commits

..
Author SHA1 Message Date
LukeGus 677c693c87 chore: pin bundled plugins to the latest releases 2026-10-18 00:29:45 -05:00
LukeGus b9193be951 feat: add shell.rightEdge slot so plugin panels can push the page over 2026-10-17 23:05:00 -05:00
LukeGus ee42837fc6 chore: changelog for merged sync, audit, event and desktop PRs 2026-10-17 19:04:52 -05:00
ZacharyZcR b63852ee5c feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)
ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.
2026-10-17 19:03:19 -05:00
Lukas 3892381b95 fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)
* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.
2026-10-17 19:03:16 -05:00
Lukas 4a9c434cd1 fix: show the login for signed-out full-screen view links and return to them (#1669)
A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.
2026-10-17 19:03:13 -05:00
ZacharyZcR 16f2f509f2 feat(plugins): publish host.created when a host is added (#1668)
Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.
2026-10-17 19:03:08 -05:00
ZacharyZcR 893e7e5a4b fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)
Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.
2026-10-17 19:03:01 -05:00
ZacharyZcR 11f6910d51 fix(sync): keep the changes cursor within the 32-bit seq column (#1665)
The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.
2026-10-17 19:02:56 -05:00
LukeGus 6ccee9b336 feat: build the Windows desktop app for ARM64 (#1615) 2026-10-16 23:42:18 -05:00
LukeGus 49f1914e6e fix: pick a free port for the desktop backend when 30001 is taken (#1663) 2026-10-16 23:37:00 -05:00
LukeGus 9949d0711e fix: move macOS split pane navigation to Cmd+Option+Arrow 2026-10-16 23:28:24 -05:00
LukeGus 6bacb17390 chore: changelog for merged plugin name and host auth type PRs 2026-10-16 23:10:28 -05:00
ZacharyZcR 65d10b37cf fix(hosts): reject or infer a missing authType on host create (#1664)
Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.
2026-10-16 23:09:55 -05:00
ZacharyZcRandZacharyZcR 764d034098 fix: show plugin names and descriptions in the UI language (#1658)
* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
2026-10-16 23:09:50 -05:00
LukeGus fc77b6c756 chore: update remote desktop origin comment for the built-in engine 2026-10-16 23:04:28 -05:00
LukeGus f76a1adfb8 chore: drop guacd from compose files and dev runner 2026-10-16 23:01:57 -05:00
LukeGus 8e809aa760 feat: plugin packs include LICENSE and NOTICE 2026-10-16 22:21:44 -05:00
LukeGus 7066215bd9 feat: plugin builds bundle worker entries and import wasm and fonts as bytes 2026-10-16 21:43:07 -05:00
LukeGus 214372d104 chore: update the overview video and add a play button to its thumbnail 2026-10-16 18:20:53 -05:00
LukeGus bb9bca5303 chore: add the overview video to the readme 2026-10-16 16:24:11 -05:00
LukeGus cf62f108d2 chore: update translations 2026-10-16 15:14:15 +00:00
LukeGus 5cc65d87cd feat(sdk): let a plugin raise its public route rate limit 2026-10-15 22:18:48 -05:00
LukeGus e166994ec4 fix: use the vite proxy for plugin sockets on any dev port 2026-10-15 22:05:08 -05:00
LukeGus 3a851ad189 feat: setting to let the hover side menu push content again 2026-10-15 22:00:24 -05:00
LukeGus d2851982a6 fix(sdk): skip comment-only chunks when splitting migrations 2026-10-15 21:52:11 -05:00
LukeGus cc5dce18e2 fix: show why a client tunnel failed to start (Plugin-Tunnels#2) 2026-10-15 21:42:28 -05:00
LukeGus 6084ed4d3c fix: say the server is down and keep retrying instead of a database error (#1637) 2026-10-15 21:40:07 -05:00
LukeGus c3f6db69e9 fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) 2026-10-15 21:15:36 -05:00
LukeGus 8e2221cb8f feat: sort the dashboard host status card (Plugin-Host-Metrics#6) 2026-10-15 21:11:58 -05:00
LukeGus 7324982f47 feat: option to hide the sidebar after opening a host (#1652) 2026-10-15 21:06:47 -05:00
LukeGus f20d64b4f2 chore: test that an update keeps a changed auth type (#1601) 2026-10-15 21:04:04 -05:00
LukeGus aa0b32a142 fix: stop building ssh2 native crypto in the docker image (#1644) 2026-10-15 21:02:54 -05:00
LukeGus c8a907538e chore: add changelog entries for 26.10.2 fixes 2026-10-15 21:02:13 -05:00
LukeGus dd5573cc10 fix: smooth host editor jumps to lower sections 2026-10-15 15:08:40 -05:00
ZacharyZcR 071711dafc fix(sync): don't delete a local row when the server can't send its copy (#1650)
* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records
2026-10-15 14:59:59 +08:00
ZacharyZcR bc74fba3f5 fix(settings): leave user manage view when switching admin sections (#1649)
The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.
2026-10-15 14:55:00 +08:00
Ferdina Kusumah 694dc34b19 fix(i18n): keep Indonesian technical terms consistent (#1636)
Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.
2026-10-15 14:24:11 +08:00
ZacharyZcR b3b08b4ff2 fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)
* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint
2026-10-14 09:11:55 +08:00
ZacharyZcR 9b0af07607 fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)
The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.
2026-10-14 08:59:57 +08:00
ZacharyZcR c044dbde87 fix(sync): send shared hosts with text columns in their stored form (#1631)
resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.
2026-10-14 08:59:52 +08:00
ZacharyZcR 1396b3a70c fix(desktop): find spawn-helper in universal macOS builds (#1630)
The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.
2026-10-14 08:59:46 +08:00
ZacharyZcR 4c9ee5923e fix(sidebar): sort hosts by IP address numerically (#1629) 2026-10-14 08:59:40 +08:00
ZacharyZcR 25e58a6425 fix(desktop): harden external editor and session cookie IPC (#1627)
* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization
2026-10-14 08:22:15 +08:00
ZacharyZcR 7d13b2a9fe fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)
* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys
2026-10-14 08:20:09 +08:00
ZacharyZcR e610aba0e5 fix: enforce unique usernames (#1628)
* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration
2026-10-14 08:17:47 +08:00
ZacharyZcR 3941dd4ecc fix: keep foreign keys on during user data import (#1626)
Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.
2026-10-14 08:13:04 +08:00
ZacharyZcR 872e5eba73 fix: check trusted proxy login against nginx's peer, not nginx (#1625)
The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.
2026-10-14 08:12:58 +08:00
ZacharyZcR 8c3f816407 fix: only trust the server's origin in the desktop login frame (#1624)
The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.
2026-10-14 08:12:53 +08:00
ZacharyZcR 13f7aa95a3 fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)
The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.
2026-10-14 08:12:47 +08:00
ZacharyZcR 8e5a50623c fix(electron): validate external login URL scheme and callback port (#1621) 2026-10-14 08:12:42 +08:00
ZacharyZcR d791e52d82 fix: stop auto-accepting changed jump host keys (#1620)
A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.
2026-10-14 08:12:37 +08:00
ZacharyZcR fb04edc497 fix: keep concurrent ssh pool connects from overwriting each other (#1619) 2026-10-14 08:12:16 +08:00
LukeGus 2fe7530cd4 chore: update paths for renamed local folders 2026-10-13 16:33:20 -05:00
LukeGus 4389bf2013 chore: simplify readme and docs wording 2026-10-12 12:46:26 -05:00
LukeGus 50f80b975d fix: cancel pending app store review before submitting a new version 2026-10-12 12:41:09 -05:00
LukeGus 1ccb30f0a7 chore: only build update files for the release's own channel 2026-10-12 12:30:52 -05:00
LukeGus d44a504c04 chore: bump Homebrew cask to 26.10.1 2026-10-12 17:27:17 +00:00
62ac931468 v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
2026-10-12 11:35:48 -05:00
LukeGus 6873bc92bb fix: drop readme icon height so github skips the placeholder bg 2026-10-10 21:21:47 -05:00
LukeGus 02f0c0c259 fix: load readme icon from termix.site 2026-10-10 18:53:17 -05:00
LukeGus db317283ac fix: bust cached readme icon 2026-10-10 17:04:45 -05:00
LukeGus 3ebe8d0fac fix: run submit and cask jobs after stable releases 2026-10-10 16:18:16 -05:00
LukeGus a074c10205 chore: re-pin plugins 2026-10-10 16:12:15 -05:00
LukeGus 55b11598f5 chore: pull sponsor logos from the docs site 2026-10-10 20:09:31 +00:00
Luke Gustafson ebbbbc2696 Replace bullet points with pipe separators in README 2026-10-10 14:59:43 -05:00
0149260926 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>
2026-10-10 14:58:27 -05:00
LukeGus a075ff4808 chore: add publish sdk workflow to main 2026-10-09 16:21:15 -05:00
Luke Gustafson 2acd858ca4 Update copyright year in LICENSE file 2026-10-06 20:48:27 -05:00
LukeGus 064fb7ea3c chore: bump Homebrew cask to 2.9.2 2026-10-06 02:12:33 +00:00
Luke Gustafson 2b309e9582 release-2.9.2 (#1566)
* 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
2026-10-05 20:24:46 -05:00
367 changed files with 116813 additions and 41665 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+167 -154
View File
@@ -117,6 +117,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -134,7 +143,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -152,6 +166,14 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -168,6 +190,14 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -178,6 +208,22 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_update_meta
path: |
release/*.yml
release/termix_windows_x64_nsis.exe.blockmap
!release/builder-debug.yml
retention-days: 30
- name: Upload Windows x64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
@@ -194,6 +240,14 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
@@ -225,6 +279,15 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -234,12 +297,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEBUG: electron-builder
run: npm run build && npx electron-builder --linux --x64
run: npm run build && npx electron-builder --linux --x64 --publish never
- name: Build Linux arm64 and armv7l
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx electron-builder --linux --arm64 --armv7l
run: npx electron-builder --linux --arm64 --armv7l --publish never
- name: Rename Linux artifacts for consistency
run: |
@@ -255,6 +318,19 @@ jobs:
cd ..
node scripts/fix-update-info.cjs \
--rename termix_linux_amd64_deb.deb=termix_linux_x64_deb.deb \
--rename termix_linux_x86_64_appimage.AppImage=termix_linux_x64_appimage.AppImage \
release/*-linux*.yml
- name: Upload Linux update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*-linux*.yml') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_update_meta
path: release/*-linux*.yml
retention-days: 30
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
@@ -439,6 +515,15 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -494,7 +579,7 @@ jobs:
run: |
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never
- name: Clean up MAS keychain before DMG build
if: steps.check_certs.outputs.has_certs == 'true'
@@ -553,7 +638,24 @@ jobs:
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
npm run build
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
npx electron-builder --mac dmg zip --universal --x64 --arm64 --publish never
# The updater picks the x64 or arm64 zip, so the universal one is left out.
- name: Fix macOS update files
run: node scripts/fix-update-info.cjs --drop universal release/*-mac.yml
- name: Upload macOS update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*-mac.yml') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_update_meta
path: |
release/*-mac.yml
release/termix_macos_x64_update.zip
release/termix_macos_x64_update.zip.blockmap
release/termix_macos_arm64_update.zip
release/termix_macos_arm64_update.zip.blockmap
retention-days: 30
- name: Verify macOS sharp packaging
run: |
@@ -771,144 +873,6 @@ jobs:
path: choco-build/*.nupkg
retention-days: 30
submit-to-flatpak:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '')
needs: []
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
id: package-version
run: |
VERSION=$(node -p "require('./package.json').version")
RELEASE_DATE=$(date +%Y-%m-%d)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
- name: Download and prepare AppImage info from public release
id: appimage-info
run: |
VERSION="${{ steps.package-version.outputs.version }}"
mkdir -p release_assets
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_X64_NAME"
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
echo "Downloading x64 AppImage from $URL_X64"
curl -L -o "$PATH_X64" "$URL_X64"
chmod +x "$PATH_X64"
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_ARM64_NAME"
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
echo "Downloading arm64 AppImage from $URL_ARM64"
curl -L -o "$PATH_ARM64" "$URL_ARM64"
chmod +x "$PATH_ARM64"
CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y imagemagick
- name: Prepare Flatpak submission files
run: |
VERSION="${{ steps.package-version.outputs.version }}"
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
mkdir -p flatpak-submission
cp packaging/flatpak/com.karmaa.termix.yml flatpak-submission/
cp packaging/flatpak/com.karmaa.termix.desktop flatpak-submission/
cp packaging/flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
cp packaging/flatpak/flathub.json flatpak-submission/
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
- name: Upload Flatpak submission as artifact
uses: actions/upload-artifact@v7
with:
name: flatpak-submission
path: flatpak-submission/*
retention-days: 30
- name: Check for Flathub token
id: check_flathub_token
run: |
if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then
echo "has_token=true" >> $GITHUB_OUTPUT
fi
- name: Open PR on Flathub repo
if: steps.check_flathub_token.outputs.has_token == 'true'
env:
GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
VERSION: ${{ steps.package-version.outputs.version }}
run: |
FLATHUB_REPO="flathub/com.karmaa.termix"
BRANCH="release-$VERSION"
git config --global user.name "LukeGus"
git config --global user.email "bugattiguy527@gmail.com"
git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo
cd flathub-repo
git checkout -b "$BRANCH"
cp ../flatpak-submission/com.karmaa.termix.yml ./
cp ../flatpak-submission/com.karmaa.termix.desktop ./
cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./
cp ../flatpak-submission/flathub.json ./
cp ../flatpak-submission/com.karmaa.termix.svg ./
cp ../flatpak-submission/icon-256.png ./
cp ../flatpak-submission/icon-128.png ./
if git diff --quiet && git diff --cached --quiet; then
echo "No changes to submit for $VERSION; Flathub repo already up to date."
exit 0
fi
git add -A
git commit -m "Update to $VERSION"
git push origin "$BRANCH"
EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true)
if [ -z "$EXISTING_PR" ]; then
gh pr create --repo "$FLATHUB_REPO" \
--base master \
--head "$BRANCH" \
--title "Update to $VERSION" \
--body "Automated release update to version $VERSION."
else
echo "PR #$EXISTING_PR already open for $BRANCH."
fi
submit-to-homebrew:
runs-on: blacksmith-6vcpu-macos-latest
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
@@ -1007,18 +971,15 @@ jobs:
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
fi
# Update files go up last: the desktop updater treats a release as ready
# once its update file is there, so every installer must already be up.
- name: Upload artifacts to latest release
run: |
cd artifacts
for dir in */; do
cd "$dir"
for file in *;
do
if [ -f "$file" ]; then
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
fi
done
cd ..
find artifacts -type f ! -name '*.yml' -print0 | while IFS= read -r -d '' file; do
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
done
find artifacts -type f -name '*.yml' -print0 | while IFS= read -r -d '' file; do
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
done
env:
GH_TOKEN: ${{ github.token }}
@@ -1050,6 +1011,15 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Check for Code Signing Certificates
id: check_certs
run: |
@@ -1188,7 +1158,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
run: |
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never
- name: Verify macOS sharp packaging
if: steps.check_certs.outputs.has_certs == 'true'
@@ -1212,6 +1182,49 @@ jobs:
--locales "en-US"
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
# Apple won't create a new version while the last one is still in
# review. Pull it out so deliver can rename it to this version.
- name: Cancel pending App Store review
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
LANE_DIR="$RUNNER_TEMP/asc_cancel/fastlane"
mkdir -p "$LANE_DIR"
cat > "$LANE_DIR/Fastfile" <<'EOF'
default_platform(:mac)
lane :cancel_pending_review do
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file("/tmp/asc_keys/api_key.json")
app = Spaceship::ConnectAPI::App.find("com.karmaa.termix")
UI.user_error!("App not found") unless app
app.get_app_store_versions(filter: { platform: "MAC_OS" }, limit: 5).each do |v|
UI.message("Version #{v.version_string}: #{v.app_version_state || v.app_store_state}")
end
active = %w[WAITING_FOR_REVIEW IN_REVIEW UNRESOLVED_ISSUES]
canceled = false
app.get_review_submissions(filter: { platform: "MAC_OS" }).each do |sub|
UI.message("Review submission #{sub.id}: #{sub.state}")
next unless active.include?(sub.state)
sub.cancel_submission
canceled = true
UI.important("Canceled review submission #{sub.id}")
end
next unless canceled
30.times do
busy = app.get_review_submissions(filter: { platform: "MAC_OS" }).any? do |sub|
(active + %w[CANCELING]).include?(sub.state)
end
break unless busy
sleep(10)
end
end
EOF
cd "$RUNNER_TEMP/asc_cancel" && fastlane cancel_pending_review
- name: Upload and submit to Mac App Store
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
+9
View File
@@ -24,6 +24,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Build plugin SDK
run: npm run build:sdk
+131 -79
View File
@@ -161,15 +161,62 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
verify-beta:
# Translates new or changed strings with the Termix LLM server and commits
# them to the dev branch, so the beta or stable build includes them. If the
# server is down it warns and the release goes on with what is there.
translate:
needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
sha: ${{ steps.commit.outputs.sha }}
steps:
- name: Checkout dev branch
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.sha || needs.prep.outputs.dev_branch }}
fetch-depth: 1
token: ${{ secrets.TERMIX_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Translate
uses: Termix-SSH/Termix-Registry/translate@main
with:
source: src/ui/locales/en.json
out: src/ui/locales/translated
languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Commit translations
id: commit
run: |
git add src/ui/locales
if git diff --cached --quiet; then
echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit these translation changes:"
git --no-pager diff --cached --stat
else
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git commit -m "chore: update translations"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
verify-beta:
needs: [prep, translate]
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout beta commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.sha }}
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
fetch-depth: 1
- name: Setup Node.js
@@ -181,6 +228,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Build plugin SDK
run: npm run build:sdk
@@ -197,7 +253,7 @@ jobs:
run: npm run test
normalize:
needs: [prep]
needs: [prep, translate]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
@@ -253,7 +309,7 @@ jobs:
git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
crowdin:
sync-main:
needs: [prep, normalize]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
@@ -265,79 +321,27 @@ jobs:
fetch-depth: 0
token: ${{ secrets.TERMIX_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Merge main into dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git fetch origin main
git merge origin/main -X ours --no-edit || true
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
- name: Clean up stale Crowdin git integration branch
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
if [ -n "$PR_NUMBER" ]; then
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
# -X ours leaves modify/delete conflicts, so settle those to the dev side too
if ! git merge origin/main -X ours --no-edit; then
git diff --name-only --diff-filter=U | while read -r f; do
if git cat-file -e "HEAD:$f" 2>/dev/null; then
git checkout --ours -- "$f" && git add -- "$f"
else
git rm -q -- "$f"
fi
done
git commit --no-edit
fi
git push origin --delete i18n_translate || true
- name: Upload sources to Crowdin
uses: crowdin/github-action@v3
with:
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
# Off until the machine translation credits are topped up.
- name: Pre-translate untranslated strings
if: false
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
run: node scripts/crowdin-pretranslate.cjs
- name: Download translations from Crowdin
uses: crowdin/github-action@v3
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Commit translations to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git add src/ui/locales/translated
if git diff --cached --quiet; then
echo "No translation changes to commit."
exit 0
fi
git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
needs: [prep, translate, normalize, sync-main]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.translate.result == 'success' || needs.translate.result == 'skipped') && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
@@ -405,14 +409,14 @@ jobs:
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, verify-beta, merge-to-main]
needs: [prep, translate, verify-beta, merge-to-main]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }}
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prep.outputs.version }}
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
secrets: inherit
create-release:
@@ -470,7 +474,7 @@ jobs:
fi
create-beta-release:
needs: [prep, docker]
needs: [prep, translate, docker]
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -479,7 +483,7 @@ jobs:
- name: Checkout beta commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.sha }}
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
fetch-depth: 0
- name: Setup Node.js
@@ -492,7 +496,7 @@ jobs:
node scripts/generate-beta-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
--branch "${{ needs.prep.outputs.dev_branch }}" \
--sha "${{ needs.prep.outputs.sha }}" \
--sha "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}" \
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
- name: Create beta release
@@ -508,11 +512,19 @@ jobs:
else
gh release create "$TAG" --repo ${{ github.repository }} \
--title "$TITLE" --notes-file BETA_BODY.md \
--prerelease --target "${{ needs.prep.outputs.sha }}"
--prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}"
fi
electron-release:
needs: [prep, merge-to-main, docker, create-release, create-beta-release]
needs:
[
prep,
translate,
merge-to-main,
docker,
create-release,
create-beta-release,
]
if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }}
uses: ./.github/workflows/electron.yml
with:
@@ -520,12 +532,12 @@ jobs:
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
secrets: inherit
electron-submit:
needs: [prep, merge-to-main, electron-release]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
@@ -535,7 +547,7 @@ jobs:
cask-commit-back:
needs: [prep, electron-release]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
@@ -593,8 +605,48 @@ jobs:
# The docs site reads the env catalog, API spec and download list from the
# newest stable release.
docs-refresh:
# Docs written for this release live on dev-X.Y.Z in the Docs repo. Squash
# merge it into main once the release is out. No branch means nothing to do.
docs-merge:
needs: [prep, electron-release]
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Merge the docs dev branch into main
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
REPO: Termix-SSH/Docs
VERSION: ${{ needs.prep.outputs.version }}
run: |
BRANCH="dev-$VERSION"
if ! gh api "repos/$REPO/branches/$BRANCH" > /dev/null 2>&1; then
echo "$REPO has no $BRANCH branch, nothing to merge."
exit 0
fi
AHEAD=$(gh api "repos/$REPO/compare/main...$BRANCH" -q .ahead_by)
if [ "$AHEAD" = "0" ]; then
echo "$BRANCH has nothing main doesn't, deleting it."
gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH"
exit 0
fi
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$BRANCH" --base main --state open --json number -q '.[0].number')
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr create --repo "$REPO" --base main --head "$BRANCH" \
--title "v$VERSION" --body "Docs for Termix v$VERSION." | grep -oE '[0-9]+$')
fi
gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --admin --delete-branch
STATE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state -q .state)
if [ "$STATE" != "MERGED" ]; then
echo "Docs PR #$PR_NUMBER did not merge (state: $STATE)."
exit 1
fi
docs-refresh:
needs: [prep, electron-release, docs-merge]
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
+53
View File
@@ -0,0 +1,53 @@
name: Translate
on:
workflow_dispatch:
inputs:
retranslate:
description: "Translate every string again, not only new or changed ones"
type: boolean
default: false
permissions:
contents: write
concurrency:
group: translate-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
if: ${{ startsWith(github.ref, 'refs/heads/dev-') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
token: ${{ secrets.TERMIX_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Translate
uses: Termix-SSH/Termix-Registry/translate@main
with:
source: src/ui/locales/en.json
out: src/ui/locales/translated
languages: src/ui/locales/languages.json
retranslate: ${{ inputs.retranslate }}
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Commit translations
run: |
git add src/ui/locales
if git diff --cached --quiet; then
echo "No translation changes."
exit 0
fi
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git commit -m "chore: update translations"
git push origin HEAD:"${GITHUB_REF_NAME}"
+79
View File
@@ -1,5 +1,84 @@
# Changelog
## 26.10.2
### Added
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
- Sort the dashboard's Host Status card by name, IP address or status
- Windows on ARM builds of the desktop app (installer, MSI and portable)
- Plugins get an event when a host is added
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
- Plugins can pin a panel to the right edge of the window that pushes the page over
### Changed
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
### Fixed
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
- The local terminal failed to start in the universal macOS build
- Hosts shared with you never showed up on a linked desktop
- A linked desktop could never turn on an unsigned plugin the server had enabled
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
- Usernames are now unique
- Importing user data keeps foreign keys on
- Trusted proxy login checks the address nginx saw, not nginx itself
- The desktop login frame only trusts the server's own origin
- External login URLs and callback ports are checked on the desktop app
- A changed jump host key is no longer accepted without asking
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
- Two connects to the same host at once could overwrite each other's connection
- Indonesian uses the same technical terms everywhere
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
- nginx answered 404 instead of 502 while the backend was down
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
- A linked desktop could never finish syncing with a server on Postgres
- Routine plugin activity filled the audit log and pushed out real security entries
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
## 26.10.1
### Added
- Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay
- The desktop app can update itself. Pick Automatic, Ask first or Never in Settings, Updates. Store and package manager installs keep updating the way they do now
- Community plugins. Turn on the community registry in Plugins, Registries to browse and install plugins made by other people, each version reviewed before it is listed
- Custom plugin registries. Add your own registry by its index URL; its signing key is pinned when you add it
### Changed
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
- The Plugins tab shows how many times each plugin was downloaded, in a Downloads column
- Plugins, Browse hides plugins you already have. Turn on Show installed to see them
### Fixed
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
- Plugin updates on the desktop app, synced or installed from the Plugins tab, failed to start with "Cannot find package"
- The desktop app showed plugins that only run on the server as not running and out of date
- Keep the terminal WebSocket open past nginx's 60s default
- Plugin download counts now show in the Plugins tab without waiting for the background check
- Clicking into a saved password, key passphrase or sudo password field and saving without typing erased it, which stopped sudo autofill
- The desktop app now says when its data is from 2.8 or older and needs 2.9 first, instead of only saying the local server could not start
- Client tunnels on the desktop app stopped with "Endpoint SSH connection closed" when a reverse proxy dropped their idle connection
- Typing right after opening a host from the sidebar went to the host search instead of the new session
- SSH config import failed on files that put a tab or `=` between a keyword and its value
- App icons and the favicon have rounded corners again
## 26.10.0
### Added
+8 -8
View File
@@ -25,7 +25,7 @@ Termix is a small core plus plugins. This repo is the core: hosts, credentials,
npm run dev
```
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Termix-Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
## Useful commands
@@ -38,21 +38,21 @@ npm run format # format the code with Prettier
## Making a change
1. **Fork the repository**: Click "Fork" at the top right of the [repository page](https://github.com/Termix-SSH/Termix).
2. **Create a branch**:
1. Fork the [repo](https://github.com/Termix-SSH/Termix).
2. Create a branch:
```sh
git checkout -b feature/my-new-feature
```
3. **Make your changes** and add or update tests for them.
4. **Commit your changes**:
3. Make your changes and add or update tests for them.
4. Commit your changes:
```sh
git commit -m "feat: add my new feature"
```
5. **Push to your fork**:
5. Push to your fork:
```sh
git push origin feature/my-new-feature
```
6. **Open a pull request** against the current `dev-*` branch with a clear description.
6. Open a pull request against the current `dev-*` branch with a clear description.
## Guidelines
@@ -72,4 +72,4 @@ Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Tem
## Support
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Please be as detailed as possible, preferably in English. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Add as much detail as you can, in English if possible. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
+4 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.9.1"
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
version "26.10.1"
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
@@ -12,6 +12,8 @@ cask "termix" do
strategy :github_latest
end
auto_updates true
app "Termix.app"
zap trash: [
+1
View File
@@ -1,3 +1,4 @@
Copyright 2026 Luke Gustafson
Apache License
Version 2.0, January 2004
+29 -56
View File
@@ -18,36 +18,38 @@
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
</p>
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
</div>
<br />
## Overview
Termix is a free, open source, self-hosted platform for managing your servers, built on plugins. The core is small. It keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, turn off or remove.
Termix is a free, open source, self-hosted server management platform built on plugins. The core keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, off or remove.
It runs on web, desktop and mobile, and it is a self-hosted alternative to Termius that stays free forever.
It runs on web, desktop and mobile. Think of it as a free, self-hosted Termius.
<br />
## Try the Demo
Want to see it before you install it? Try the demo at [demo.termix.site](https://demo.termix.site/). Any username and password works.
Try it at [demo.termix.site](https://demo.termix.site/). Any username and password works.
<br />
## Core
What every Termix install has, with or without plugins:
What every install has, with or without plugins:
- **Hosts:** Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
- **Credentials:** Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
- **Sharing and Roles:** Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
- **Accounts:** Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
- **Security:** Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
- **Desktop Sync:** The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
- **Interface:** Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
- **Databases:** SQLite by default, with PostgreSQL and MySQL supported too
- Hosts: Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
- Credentials: Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
- Sharing and Roles: Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
- Accounts: Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
- Security: Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
- Desktop Sync: The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
- Interface: Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
- Databases: SQLite by default, with PostgreSQL and MySQL supported too
<br />
@@ -92,7 +94,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
<br />
@@ -105,27 +107,27 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr>
<tr>
<td align="center"><b>Web</b></td>
<td>Any modern browser (Chrome, Safari, Firefox) · PWA support</td>
<td>Any modern browser (Chrome, Safari, Firefox) | PWA support</td>
</tr>
<tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable · EXE and MSI Installer · Chocolatey</td>
<td>Portable | EXE and MSI Installer | Chocolatey</td>
</tr>
<tr>
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
</tr>
<tr>
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
<td>Apple App Store · DMG · Homebrew</td>
<td>Apple App Store | DMG | Homebrew</td>
</tr>
<tr>
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
<td>Apple App Store · IPA</td>
<td>Apple App Store | IPA</td>
</tr>
<tr>
<td align="center"><b>Android</b> <sub>v7.0+</sub></td>
<td>Google Play Store · APK</td>
<td>Google Play Store | APK</td>
</tr>
</table>
@@ -133,9 +135,9 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
## Installation
Visit the [Termix Docs](https://docs.termix.site/install) for full install steps on every platform.
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
```yaml
services:
@@ -149,44 +151,23 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
volumes:
termix-data:
driver: local
networks:
termix-net:
driver: bridge
```
### Cloud Hosting
You can run Termix on a VPS instead of inside your own network. If Termix runs on the network it manages, an outage takes Termix down with it, right when you need it to fix things. Running it somewhere else keeps it reachable, gives you a static IP and lets you get in from anywhere without a VPN or port forward.
If Termix runs on the network it manages, an outage takes it down right when you need it. A VPS keeps it reachable, gives you a static IP and works from anywhere without a VPN or port forward.
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix, and the docs have a step by step guide for their VPS platform.
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix and the docs have a setup guide for their VPS.
<br />
## Donate
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains and development time.
Termix has no paid plans. Donations cover servers, domains and development time.
[Donate](https://donate.termix.site/)
@@ -194,7 +175,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
## Sponsors
Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
For a paid sponsor spot, email [mail@termix.site](mailto:mail@termix.site).
<!-- SPONSORS:START -->
@@ -202,14 +183,6 @@ Interested in a paid placement to support development? Email [mail@termix.site](
<br />
<a href="https://www.digitalocean.com/">
<img src="https://termix.site/img/sponsors/digitalocean.svg" height="40" alt="DigitalOcean" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/">
<img src="https://termix.site/img/sponsors/crowdin.svg" height="40" alt="Crowdin" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.blacksmith.sh/">
<img src="https://termix.site/img/sponsors/blacksmith.svg" height="40" alt="Blacksmith" />
</a>
@@ -250,7 +223,7 @@ Bugs and ideas for Termix itself (login, hosts, credentials, sharing, sync, the
Every plugin has its own repo, so a problem with the terminal, file manager, Docker, remote desktop or any other plugin goes in that plugin's repo (see the table above, or use Report an issue on the plugin's page in the app, which fills in your versions for you). Not sure where it goes? Open it here and it will be moved.
You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
You need a GitHub account. Add as much detail as you can, in English if possible.
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server. Update videos are on [YouTube](https://www.youtube.com/@TermixSSH/videos).
@@ -258,4 +231,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
## License
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
Apache License 2.0. See [LICENSE](LICENSE).
-9
View File
@@ -1,9 +0,0 @@
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"
preserve_hierarchy: true
files:
# Core has always lived at /en.json in Crowdin; moving it would orphan its translations.
- source: /src/ui/locales/en.json
dest: /en.json
translation: /src/ui/locales/translated/%locale_with_underscore%.json
+7 -2
View File
@@ -34,7 +34,9 @@ WORKDIR /app
COPY . .
RUN npm run build:backend
# Bundled plugin downloads show in the plugin store, so keep them between builds.
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader
@@ -72,12 +74,15 @@ COPY vendor ./vendor
COPY scripts/patch-ssh2-agent.cjs ./scripts/
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
# OpenSSL instead (chacha20-poly1305 is then left out).
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-ssh2-agent.cjs && \
rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
npm rebuild bcryptjs ssh2 && \
npm rebuild bcryptjs && \
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
npm cache clean --force
# Stage 6: Final optimized image
+68 -68
View File
@@ -3,20 +3,20 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
"sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"onboarding": {
"recommended": true,
"consent": false
@@ -25,14 +25,14 @@
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"onboarding": {
"recommended": true,
"consent": false
@@ -41,8 +41,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
"sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"onboarding": {
"recommended": true,
"consent": false
@@ -51,20 +51,20 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
"sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"onboarding": {
"recommended": true,
"consent": false
@@ -73,32 +73,32 @@
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
"sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
"sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"onboarding": {
"recommended": true,
"consent": false
@@ -107,14 +107,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
"sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -123,20 +123,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
"sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
"sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"onboarding": {
"recommended": true,
"consent": false
@@ -145,8 +145,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
"sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"onboarding": {
"recommended": true,
"consent": false
@@ -155,8 +155,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"onboarding": {
"recommended": false,
"consent": false,
@@ -166,20 +166,20 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
"sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"onboarding": {
"recommended": true,
"consent": true
@@ -188,20 +188,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
"sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
"sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"onboarding": {
"recommended": true,
"consent": false
@@ -210,8 +210,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"onboarding": {
"recommended": true,
"consent": false
@@ -220,32 +220,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
"sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
"sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"onboarding": {
"recommended": true,
"consent": false
@@ -254,8 +254,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"onboarding": {
"recommended": true,
"consent": false
-15
View File
@@ -12,24 +12,9 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+5 -18
View File
@@ -9,12 +9,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -28,21 +22,14 @@ services:
# Trusted reverse-proxy authentication is disabled by default. When
# enabled, do not expose this container directly to untrusted clients.
# TRUSTED_PROXY_AUTH_ENABLED: "true"
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
# List the address your authenticating proxy connects from, as this
# container sees it (for a proxy container on termix-net, its IP there).
# Keep it narrow: published ports can reach the container from the
# Docker gateway, so do not trust whole private ranges.
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+17 -1
View File
@@ -200,6 +200,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -425,6 +428,16 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
# A terminal is quiet most of the time - waiting on a prompt, reading
# output, running something long. Every console plugin dials this route
# (terminal, serial, remote desktop), and nginx's default
# proxy_read_timeout of 60s closed the socket about a minute into any
# pause, which reaches the user as the SSH server disconnecting. The
# plugin WebSocket routes below already ask for 86400s; this is the same
# class of socket, so it asks for the same.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
location /host/ {
@@ -463,9 +476,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
+17 -1
View File
@@ -169,6 +169,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -394,6 +397,16 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
# A terminal is quiet most of the time - waiting on a prompt, reading
# output, running something long. Every console plugin dials this route
# (terminal, serial, remote desktop), and nginx's default
# proxy_read_timeout of 60s closed the socket about a minute into any
# pause, which reaches the user as the SSH server disconnecting. The
# plugin WebSocket routes below already ask for 86400s; this is the same
# class of socket, so it asks for the same.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
location /host/ {
@@ -432,9 +445,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
@@ -0,0 +1 @@
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
+19
View File
@@ -0,0 +1,19 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
)
)
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
WHERE ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -498,6 +498,20 @@
"when": 1791550497926,
"tag": "0070_drop_donation_modal",
"breakpoints": true
},
{
"idx": 71,
"version": "5",
"when": 1791755801066,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "5",
"when": 1791935283211,
"tag": "0072_unique_username",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
+18
View File
@@ -0,0 +1,18 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -498,6 +498,20 @@
"when": 1791550496661,
"tag": "0070_drop_donation_modal",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1791755800419,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1791935282340,
"tag": "0072_unique_username",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
+17
View File
@@ -0,0 +1,17 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -463,6 +463,20 @@
"when": 1791550495357,
"tag": "0065_drop_donation_modal",
"breakpoints": true
},
{
"idx": 66,
"version": "6",
"when": 1791755799726,
"tag": "0066_drop_install_count_source",
"breakpoints": true
},
{
"idx": 67,
"version": "6",
"when": 1791935281343,
"tag": "0067_unique_username",
"breakpoints": true
}
]
}
+12 -4
View File
@@ -1,7 +1,10 @@
{
"appId": "com.karmaa.termix",
"productName": "Termix",
"publish": null,
"publish": {
"provider": "generic",
"url": "https://github.com/Termix-SSH/Termix/releases/latest/download"
},
"directories": {
"output": "release"
},
@@ -43,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
},
{
"target": "msi",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
}
],
"icon": "public/icon.ico",
@@ -114,8 +117,13 @@
{
"target": "dmg",
"arch": ["universal", "x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"artifactName": "termix_macos_${arch}_update.${ext}",
"icon": "public/icon.icns",
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
@@ -148,6 +156,6 @@
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
},
"generateUpdatesFilesForAllChannels": true,
"generateUpdatesFilesForAllChannels": false,
"msiProjectCreated": "packaging/build/msi-shortcuts.cjs"
}
+323
View File
@@ -0,0 +1,323 @@
// Desktop self updates. The release is picked from the GitHub releases list
// instead of electron-updater's GitHub provider, because the repo also holds
// SDK and 2.9.x releases, and a new release exists a while before its files
// finish uploading. The channel file is uploaded last, so a release counts
// only once it has one for this platform.
const path = require("path");
const REPO = "Termix-SSH/Termix";
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/`;
const MODES = ["automatic", "prompt", "never"];
const DEFAULT_MODE = "prompt";
const FIRST_CHECK_DELAY = 30 * 1000;
const CHECK_INTERVAL = 6 * 60 * 60 * 1000;
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
function parseVersion(value) {
const match = VERSION.exec(String(value || "").trim());
if (!match) return null;
return {
parts: match.slice(1, 4).map(Number),
beta: match[4] == null ? null : Number(match[4]),
};
}
// A beta sorts before the release it leads up to.
function compareVersions(a, b) {
for (let i = 0; i < 3; i++) {
if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
}
if (a.beta === b.beta) return 0;
if (a.beta == null) return 1;
if (b.beta == null) return -1;
return a.beta - b.beta;
}
// Matches the names electron-builder writes and electron-updater reads.
function channelFile(channel, platform, arch) {
if (platform === "win32") return `${channel}.yml`;
if (platform === "darwin") return `${channel}-mac.yml`;
return `${channel}-linux${arch === "x64" ? "" : `-${arch}`}.yml`;
}
/**
* The newest release this install should move to, or null. Stable installs
* only see stable releases; a beta install also sees newer betas.
*/
function resolveRelease(releases, { currentVersion, platform, arch }) {
const current = parseVersion(currentVersion);
if (!current || !Array.isArray(releases)) return null;
const allowBeta = current.beta != null;
const newest = releases
.filter((release) => release && !release.draft)
.map((release) => ({ release, version: parseVersion(release.tag_name) }))
.filter(
({ release, version }) =>
version &&
release.tag_name.startsWith("v") &&
(allowBeta || version.beta == null) &&
compareVersions(version, current) > 0,
)
.sort((a, b) => compareVersions(b.version, a.version))[0];
if (!newest) return null;
const { release, version } = newest;
const channel = version.beta == null ? "latest" : "beta";
const file = channelFile(channel, platform, arch);
const assets = Array.isArray(release.assets) ? release.assets : [];
if (!assets.some((asset) => asset && asset.name === file)) return null;
return {
tag: release.tag_name,
version: release.tag_name.slice(1),
channel,
feedUrl: `${DOWNLOAD_BASE}${release.tag_name}`,
releaseUrl:
release.html_url ||
`https://github.com/${REPO}/releases/tag/${release.tag_name}`,
};
}
/**
* How this copy of the app was installed, which decides whether it can
* replace itself:
* - auto: NSIS, DMG, AppImage or deb, updated in place
* - manual: MSI, Chocolatey, Flatpak, portable builds and 32-bit Windows, told where to download
* - store: Mac App Store
* - disabled: dev builds
*/
function getInstallKind({
platform,
arch,
isPackaged,
mas,
env,
execPath,
resourcesPath,
exists,
readFile,
}) {
if (!isPackaged) return "disabled";
if (mas) return "store";
if (platform === "linux") {
if (env.FLATPAK_ID || exists("/.flatpak-info")) return "manual";
if (env.APPIMAGE) return "auto";
try {
const type = readFile(path.join(resourcesPath, "package-type")).trim();
// The AUR package reuses the deb's files, so ask dpkg if it owns us.
if (type === "deb" && exists("/var/lib/dpkg/info/termix.list")) {
return "auto";
}
} catch {
// Not a package install.
}
return "manual";
}
if (platform === "win32") {
if (arch !== "x64") return "manual";
const uninstaller = path.join(
path.dirname(execPath),
"Uninstall Termix.exe",
);
return exists(uninstaller) ? "auto" : "manual";
}
if (platform === "darwin") {
// Still running from the mounted DMG.
return execPath.startsWith("/Volumes/") ? "manual" : "auto";
}
return "manual";
}
function normalizeMode(mode) {
return MODES.includes(mode) ? mode : DEFAULT_MODE;
}
function createUpdater({
currentVersion,
platform,
arch,
kind,
getMode,
saveMode,
fetchReleases,
loadAutoUpdater,
send,
log,
beforeInstall,
}) {
const state = {
kind,
mode: normalizeMode(getMode()),
currentVersion,
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
};
const enabled = kind === "auto" || kind === "manual";
let updater = null;
let timer = null;
let checkedOnce = false;
function update(patch) {
Object.assign(state, patch);
send({ ...state });
}
function getUpdater() {
if (updater) return updater;
updater = loadAutoUpdater();
updater.logger = {
info: (...args) => log("[updater]", ...args),
warn: (...args) => log("[updater]", ...args),
error: (...args) => log("[updater]", ...args),
debug: () => {},
};
updater.autoInstallOnAppQuit = true;
updater.on("update-available", (info) =>
update({
status: updater.autoDownload ? "downloading" : "available",
version: info.version,
progress: null,
error: null,
}),
);
updater.on("update-not-available", () =>
update({ status: "up-to-date", error: null }),
);
updater.on("download-progress", (progress) =>
update({
status: "downloading",
progress: Math.round(progress.percent || 0),
}),
);
updater.on("update-downloaded", (info) =>
update({
status: "downloaded",
version: info.version,
progress: 100,
error: null,
}),
);
updater.on("error", (error) => {
log("[updater] error:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
});
return updater;
}
async function check() {
if (!enabled) return { ...state };
if (["checking", "downloading", "downloaded"].includes(state.status)) {
return { ...state };
}
checkedOnce = true;
update({ status: "checking", error: null });
let release;
try {
release = resolveRelease(await fetchReleases(), {
currentVersion,
platform,
arch,
});
} catch (error) {
log("[updater] release lookup failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
return { ...state };
}
if (!release) {
update({ status: "up-to-date" });
return { ...state };
}
update({ version: release.version, releaseUrl: release.releaseUrl });
if (kind === "manual") {
update({ status: "available" });
return { ...state };
}
try {
const auto = getUpdater();
auto.setFeedURL({ provider: "generic", url: release.feedUrl });
auto.channel = release.channel;
auto.allowDowngrade = false;
auto.autoDownload = state.mode === "automatic";
await auto.checkForUpdates();
} catch (error) {
log("[updater] check failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
}
return { ...state };
}
async function download() {
if (kind !== "auto" || state.status !== "available") return { ...state };
update({ status: "downloading", progress: 0 });
try {
await getUpdater().downloadUpdate();
} catch (error) {
log("[updater] download failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
}
return { ...state };
}
function install() {
if (kind !== "auto" || state.status !== "downloaded") return false;
// The tray keeps windows alive on close, so mark the quit first.
beforeInstall?.();
setImmediate(() => getUpdater().quitAndInstall());
return true;
}
function setMode(mode) {
if (!MODES.includes(mode)) return { ...state };
saveMode(mode);
update({ mode });
if (updater) updater.autoDownload = mode === "automatic";
if (mode !== "never" && !checkedOnce) void check();
return { ...state };
}
function start() {
if (!enabled || timer) return;
setTimeout(() => {
if (state.mode !== "never") void check();
}, FIRST_CHECK_DELAY).unref?.();
timer = setInterval(() => {
if (state.mode !== "never") void check();
}, CHECK_INTERVAL);
timer.unref?.();
}
return {
getState: () => ({ ...state }),
check,
download,
install,
setMode,
start,
};
}
module.exports = {
DEFAULT_MODE,
RELEASES_API,
channelFile,
compareVersions,
createUpdater,
getInstallKind,
normalizeMode,
parseVersion,
resolveRelease,
};
+6
View File
@@ -35,7 +35,13 @@ function parseConflictingPort(stderr) {
* stderr. Always returns a failure -- see the note above on why every exit
* that reaches this point counts as one.
*/
// Part of PRE_29_DATABASE_ERROR in src/backend/database/db/upgrade-guard.ts.
const OLD_DATABASE_MARKER = "This database is from Termix 2.8 or older";
function classifyBackendFailure(stderr = "") {
if (stderr.includes(OLD_DATABASE_MARKER)) {
return { reason: "old-database", port: null };
}
if (stderr.includes("EADDRINUSE")) {
return { reason: "port-in-use", port: parseConflictingPort(stderr) };
}
+1 -1
View File
@@ -32,7 +32,7 @@ function writeJson(filePath, value) {
}
}
const DEFAULTS = { defaultConnectionOrigin: "local" };
const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" };
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
+70
View File
@@ -0,0 +1,70 @@
const path = require("path");
// Saved by the file manager after the user picks an editor in a file dialog.
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
// Extensions the OS would run rather than open in an editor. With no editor
// configured the temp copy goes to shell.openPath, and its name comes from
// the remote server.
const LAUNCHABLE_EXTENSIONS = new Set([
// Windows
"application",
"appref-ms",
"bat",
"chm",
"cmd",
"com",
"cpl",
"exe",
"gadget",
"hta",
"inf",
"jar",
"js",
"jse",
"library-ms",
"lnk",
"msc",
"msi",
"msp",
"mst",
"pif",
"ps1",
"psd1",
"psm1",
"py",
"pyw",
"reg",
"scf",
"scr",
"search-ms",
"settingcontent-ms",
"url",
"vbe",
"vbs",
"ws",
"wsf",
"wsh",
"xll",
// macOS
"app",
"command",
"pkg",
"terminal",
"tool",
"workflow",
// Linux
"appimage",
"desktop",
"run",
"sh",
]);
function isLaunchableFileName(fileName) {
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
const extension = path.extname(name).slice(1).toLowerCase();
return LAUNCHABLE_EXTENSIONS.has(extension);
}
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
+20
View File
@@ -0,0 +1,20 @@
/**
* The login URL a server hands the desktop app goes to shell.openExternal,
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
* would launch whatever OS handler is registered for it.
*/
function isExternalLoginUrl(value) {
try {
const { protocol } = new URL(value);
return protocol === "https:" || protocol === "http:";
} catch {
return false;
}
}
/** A non-privileged port for the loopback callback listener. */
function isCallbackPort(value) {
return Number.isInteger(value) && value >= 1024 && value <= 65535;
}
module.exports = { isExternalLoginUrl, isCallbackPort };
+8 -1
View File
@@ -6,9 +6,16 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
return new URL(url).origin;
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
} catch {
return null;
}
+8 -2
View File
@@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001";
let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
}
return {
url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
@@ -1050,6 +1055,7 @@ module.exports = {
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
setLocalBackendPort,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+5 -1
View File
@@ -39,7 +39,11 @@ function resolveLocalShell(
* how many helpers were fixed.
*/
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
// A universal macOS build ships app-arm64.asar and app-x64.asar.
const dir = ptyDir.replace(
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
"$1.asar.unpacked",
);
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
+167 -115
View File
@@ -29,12 +29,21 @@ const pty = require("node-pty");
const WebSocket = require("ws");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const autoUpdates = require("./auto-updater.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const {
EXTERNAL_EDITOR_SETTING,
isLaunchableFileName,
} = require("./external-editor.cjs");
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
const localTerminalSessions = new Map();
@@ -433,14 +442,6 @@ function isInsecureModeEnabled() {
);
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
@@ -484,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
return linkedServer.isLinkedOrigin(url);
}
function getTlsVerificationOptions(url) {
@@ -699,6 +700,9 @@ let tray = null;
let isQuitting = false;
const tempFiles = new Map();
const externalEditorSessions = new Map();
// Paths the user picked in an open dialog this session. Only these may be
// saved as the external editor, so the renderer cannot name an executable.
const dialogChosenPaths = new Set();
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
@@ -1115,6 +1119,35 @@ function reapOrphanedBackendProcess() {
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
@@ -1155,7 +1188,8 @@ function startBackendServer() {
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: "30001",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1420,6 +1454,7 @@ function createWindow() {
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
@@ -1662,6 +1697,12 @@ ipcMain.handle("get-embedded-server-status", () => {
ipcMain.handle(
"external-browser-login",
async (_event, authUrl, callbackPort) => {
if (!isExternalLoginUrl(authUrl)) {
return { success: false, error: "Invalid authentication URL" };
}
if (!isCallbackPort(callbackPort)) {
return { success: false, error: "Invalid callback port" };
}
const http = require("http");
return new Promise((resolve) => {
@@ -1752,6 +1793,65 @@ ipcMain.handle("save-desktop-settings", (_event, settings) => {
return desktopSettings.saveDesktopSettings(settings);
});
let appUpdater = null;
function startAppUpdater() {
const kind = autoUpdates.getInstallKind({
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
mas: !!process.mas,
env: process.env,
execPath: process.execPath,
resourcesPath: process.resourcesPath,
exists: (target) => fs.existsSync(target),
readFile: (target) => fs.readFileSync(target, "utf8"),
});
logToFile("Updater install kind:", kind);
appUpdater = autoUpdates.createUpdater({
currentVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
kind,
getMode: () => desktopSettings.getDesktopSettings().updateMode,
saveMode: (mode) =>
desktopSettings.saveDesktopSettings({ updateMode: mode }),
fetchReleases: async () => {
const { net } = require("electron");
const response = await net.fetch(autoUpdates.RELEASES_API, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response.ok) {
throw new Error(`GitHub releases returned ${response.status}`);
}
return response.json();
},
loadAutoUpdater: () => require("electron-updater").autoUpdater,
send: (state) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("desktop-update-state", state);
}
},
log: logToFile,
beforeInstall: () => {
isQuitting = true;
},
});
appUpdater.start();
}
ipcMain.handle(
"desktop-update-get-state",
() => appUpdater?.getState() ?? null,
);
ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null);
ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null);
ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false);
ipcMain.handle(
"desktop-update-set-mode",
(_event, mode) => appUpdater?.setMode(mode) ?? null,
);
// The server an older version of the app was pointed at, offered again when
// linking so upgraded installs do not have to look it up.
ipcMain.handle("get-previous-server-url", () => {
@@ -1924,6 +2024,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// A proxy in front of the server may drop an idle WebSocket, which stopped
// the tunnel. Pings count as traffic.
function keepC2SSocketAlive(ws) {
const timer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
}, 25_000);
timer.unref?.();
ws.once("close", () => clearInterval(timer));
}
// C2S (client-to-server) tunnels relay through the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
@@ -2192,6 +2302,7 @@ async function openC2SRelay(
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
const pendingChunks = [];
let ready = false;
let closed = false;
@@ -2299,10 +2410,13 @@ async function testC2SRelay(
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
if (runtime) runtime.authSocket = ws;
return new Promise((resolve) => {
let settled = false;
// The relay sends its reason and then closes; keep the reason.
let relayError = null;
const settle = (result) => {
if (settled) return;
settled = true;
@@ -2339,6 +2453,10 @@ async function testC2SRelay(
settle({ success: true });
} else if (message.type === "error") {
clearTimeout(timer);
relayError = normalizeC2SErrorMessage(
message.error,
"Tunnel test failed",
);
settle(createC2SFailure(message.error, "Tunnel test failed"));
}
} catch (error) {
@@ -2352,7 +2470,10 @@ async function testC2SRelay(
});
ws.on("close", () => {
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
setC2STunnelError(
tunnel.name,
relayError || "Endpoint SSH connection closed",
);
}
clearTimeout(timer);
settle({ success: false, error: "Tunnel test connection closed" });
@@ -2609,6 +2730,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
const sockets = new Map();
let closed = false;
@@ -3083,18 +3205,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
return `[${date}] ${computerName} (${platform} ${release})`;
});
function readSetting(key) {
const settingsPath = path.join(app.getPath("userData"), "settings.json");
if (!fs.existsSync(settingsPath)) return null;
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
return settings[key] !== undefined ? settings[key] : null;
}
ipcMain.handle("get-setting", (event, key) => {
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
if (!fs.existsSync(settingsPath)) {
return null;
}
const settingsData = fs.readFileSync(settingsPath, "utf8");
const settings = JSON.parse(settingsData);
return settings[key] !== undefined ? settings[key] : null;
return readSetting(key);
} catch (error) {
console.error("Error reading setting:", error);
return null;
@@ -3102,6 +3222,16 @@ ipcMain.handle("get-setting", (event, key) => {
});
ipcMain.handle("set-setting", (event, key, value) => {
if (typeof key !== "string") {
return { success: false, error: "Invalid setting key" };
}
if (
key === EXTERNAL_EDITOR_SETTING &&
value &&
!dialogChosenPaths.has(value)
) {
return { success: false, error: "Choose the editor in the file dialog" };
}
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
@@ -3125,94 +3255,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
}
});
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
try {
const ses = mainWindow?.webContents?.session;
if (!ses) return null;
const cookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const cookie = cookies.find((candidate) =>
cookieMatchesUrl(candidate, targetUrl),
);
return (
cookie?.value ||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
null
);
} catch (error) {
console.error("Failed to get session cookie:", error);
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
}
});
function cookieMatchesUrl(cookie, targetUrl) {
if (!targetUrl) return true;
try {
const targetHost = new URL(targetUrl).hostname;
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
return (
cookieDomain === targetHost ||
targetHost.endsWith(`.${cookieDomain}`) ||
(!cookieDomain && targetHost === "localhost")
);
} catch {
return true;
}
}
ipcMain.handle(
"wait-session-cookie",
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
const ses = mainWindow?.webContents?.session;
if (!ses) return { success: false, error: "No Electron session" };
const existingCookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const existingCookie = existingCookies.find((cookie) =>
cookieMatchesUrl(cookie, targetUrl),
);
if (existingCookie?.value && existingCookie.value !== previousValue) {
return { success: true, value: existingCookie.value };
}
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
return { success: true, value: rememberedCookie.value };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ses.cookies.off("changed", onCookieChanged);
resolve({ success: false, error: "Timed out waiting for cookie" });
}, timeoutMs);
function onCookieChanged(_event, cookie, _cause, removed) {
if (
removed ||
cookie.name !== name ||
!cookie.value ||
cookie.value === previousValue ||
!cookieMatchesUrl(cookie, targetUrl)
) {
return;
}
clearTimeout(timeout);
ses.cookies.off("changed", onCookieChanged);
resolve({ success: true, value: cookie.value });
}
ses.cookies.on("changed", onCookieChanged);
});
},
);
ipcMain.handle("clear-session-cookies", async () => {
try {
clearPersistedElectronAuthCookies();
@@ -3340,7 +3382,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
});
ipcMain.handle("show-open-dialog", async (_event, options) => {
return dialog.showOpenDialog(mainWindow, options || {});
const result = await dialog.showOpenDialog(mainWindow, options || {});
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
return result;
});
function toLocalFileType(stat) {
@@ -3663,6 +3707,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
ipcMain.handle("open-external-editor", async (_event, fileData) => {
try {
// The editor comes from the saved setting, never from the caller.
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
return {
success: false,
error: "Choose an external editor to open this file type",
};
}
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
@@ -3684,10 +3737,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
lastMtimeMs: stat.mtimeMs,
});
const editorPath =
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
? fileData.editorPath.trim()
: null;
const openError = await openPathWithEditor(result.path, editorPath);
if (openError) {
closeExternalEditorSession(editId);
@@ -3791,6 +3840,8 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
@@ -3804,6 +3855,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
startAppUpdater();
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});
+26 -10
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
@@ -26,6 +35,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
// App Store builds are updated by the store.
updates: process.mas
? undefined
: {
getState: () => ipcRenderer.invoke("desktop-update-get-state"),
check: () => ipcRenderer.invoke("desktop-update-check"),
download: () => ipcRenderer.invoke("desktop-update-download"),
install: () => ipcRenderer.invoke("desktop-update-install"),
setMode: (mode) => ipcRenderer.invoke("desktop-update-set-mode", mode),
onState: (callback) => {
const listener = (_event, state) => callback(state);
ipcRenderer.on("desktop-update-state", listener);
return () =>
ipcRenderer.removeListener("desktop-update-state", listener);
},
},
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true,
isDev: process.env.NODE_ENV === "development",
@@ -70,16 +96,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
ipcRenderer.invoke(
"wait-session-cookie",
name,
targetUrl,
previousValue,
timeoutMs,
),
externalBrowserLogin: (authUrl, callbackPort) =>
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
+24 -11
View File
@@ -71,7 +71,6 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -108,7 +107,6 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -161,7 +159,6 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -204,7 +201,6 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -283,7 +279,6 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -355,16 +349,20 @@
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
"description": "Use another plugin index instead of the official registry."
},
{
"name": "TERMIX_COMMUNITY_REGISTRY_URL",
"group": "plugins",
"description": "Use another plugin index instead of the community registry. Its plugins still have to be signed with the community key."
},
{
"name": "TERMIX_PLUGIN_STATS_URL",
"group": "plugins",
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
},
{
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
@@ -401,7 +399,6 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -430,6 +427,12 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -442,14 +445,24 @@
"internal": true,
"description": "Read on Linux to pick the password store and window behavior."
},
{
"name": "APPIMAGE",
"group": "desktop",
"internal": true,
"description": "Set by AppImage. Tells the desktop app it can update itself."
},
{
"name": "FLATPAK_ID",
"group": "desktop",
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
+53 -10
View File
@@ -1,17 +1,18 @@
{
"name": "termix",
"version": "26.10.0",
"version": "26.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.0",
"version": "26.10.1",
"hasInstallScript": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -25,6 +26,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"electron-updater": "^6.8.9",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"motion": "^13.1.1",
@@ -105,6 +107,7 @@
"husky": "^9.1.7",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"js-yaml": "^4.3.2",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lucide-react": "^1.33.0",
@@ -2876,7 +2879,6 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -7589,7 +7591,6 @@
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -9016,6 +9017,34 @@
"dev": true,
"license": "ISC"
},
"node_modules/electron-updater": {
"version": "6.8.9",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.7.0",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "24.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
@@ -9870,7 +9899,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -10803,7 +10831,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@@ -10869,7 +10896,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": {
@@ -11218,6 +11244,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -11230,6 +11262,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -13020,7 +13059,6 @@
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -13780,6 +13818,12 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -14081,7 +14125,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
@@ -15015,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.5",
"version": "1.0.11",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+5 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.0",
"version": "26.10.1",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
@@ -47,6 +47,7 @@
"plugins:pin": "node scripts/pin-bundled-plugins.cjs"
},
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -60,6 +61,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"electron-updater": "^6.8.9",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"motion": "^13.1.1",
@@ -140,6 +142,7 @@
"husky": "^9.1.7",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"js-yaml": "^4.3.2",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lucide-react": "^1.33.0",
+44
View File
@@ -1,5 +1,49 @@
# Changelog
## 1.0.11
Plugin API 1.3.0.
### Added
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
## 1.0.10
### Changed
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
## 1.0.9
### Added
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
### Fixed
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
## 1.0.8
### Added
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
## 1.0.7
### Fixed
- A migration file, or a part of one, holding only comments made core fail to start the plugin
## 1.0.6
### Added
- `PLUGIN-GUIDE.md`, a guide to building, testing and releasing a plugin, written so AI coding tools can read it too
## 1.0.5
### Fixed
+823
View File
@@ -0,0 +1,823 @@
# Termix plugin guide
SDK 1.0.11 | Plugin API 1.3.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
Run `npm update @termix-ssh/plugin-sdk` to get the newest one.
If this guide and the SDK's TypeScript types disagree, the types are right. They live next to this file
in `dist/*.d.ts`. The long form of everything here is at https://docs.termix.site/develop, and the raw
Markdown of each page is at
`https://raw.githubusercontent.com/Termix-SSH/Docs/main/develop/<page>.md` (for example
`develop/backend.md`), which is easier for tools to read than the site.
## What a plugin is
Termix is self-hosted, plugin-based server management. Core keeps hosts, credentials, users, sharing and
the shell. Everything else, even the SSH terminal, is a plugin built with this SDK.
A plugin is:
- `manifest.json`: what it is, what it adds and what it may do. Termix reads it without running any code.
- A backend, `src/backend/index.ts`, run inside the Termix server process. It exports `activate(ctx)`.
- A frontend, `src/frontend/index.tsx`, run in the browser in the same page as Termix. It exports
`activate(app)`.
There is no sandbox. Capabilities make a plugin honest and easy to audit, but code runs with the server's
rights. Write it as if an admin will read every line, because reviewers do.
## Hard rules
Break one of these and the plugin fails to load, fails validation or gets rejected in review.
1. Import only `@termix-ssh/plugin-sdk/*`, your own files and npm packages. Never import Termix's own code.
2. Create everything inside `activate`. No servers, timers, sockets or connections at the top of a module.
Disable then enable runs `activate` again on the same module, and it must work without a restart.
3. Clean up what you make yourself with `ctx.disposables.add(fn)` (backend) or `app.onDispose(fn)`
(frontend). Things made through `ctx` and `app` are cleaned up for you.
4. Never call `process.exit` or touch signals.
5. Never open your own SSH connection. Use `ctx.ssh` so jump hosts, proxies, host keys, every auth type and
sharing work the way the user set them up.
6. Outbound HTTP goes through `ctx.fetch` (needs `network:outbound`), not a raw `fetch` or axios.
7. Ask for the fewest capabilities you can. A call that needs one you didn't list throws
`PluginCapabilityError`.
8. Never take a user id from a request body or a service caller. Use `ctx.currentActor()`.
9. Check permissions on the backend. Frontend checks only hide UI.
10. Call `await ctx.db.persist()` after every database write. On SQLite a write without it can be lost on
restart.
11. Never use `.returning()` in a query. MySQL doesn't have it. Read a new row back by a key you chose.
12. Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a
new one.
13. Never index a `text()` or `json()` column. Use `varchar(n)` for anything you index.
14. Never hard code UI text. Every string goes in `locales/en.json` and is read with `t()`.
15. The plugin `id` never changes once released. It names your tables, routes and permissions.
16. The version in `manifest.json`, `package.json` and the newest `CHANGELOG.md` section must match.
17. Put an `@openapi` JSDoc block above every route.
18. No obfuscated or minified source, and no code downloaded and run at runtime.
19. No tracking, telemetry or network calls the user didn't ask for. If you need one, make it off by
default and explain it in your docs.
## Project layout
```
manifest.json what the plugin is, adds and may do
package.json npm scripts and dependencies
CHANGELOG.md release notes, one section per version
README.md the repo front page
AGENTS.md notes for AI tools working on this plugin
docs/index.md the plugin's docs page (more pages next to it)
src/backend/index.ts activate(ctx), runs in the Termix server
src/backend/tables.ts table definitions, exports `tables`
src/backend/routes.ts HTTP routes
src/frontend/index.tsx activate(app), runs in the browser
locales/en.json every string the plugin shows
locales/translated/ other languages, like de_DE.json (optional)
migrations/ sqlite/, postgres/, mysql/ and snapshot.json, made by npm run migrations
tests/backend/ backend tests (Node)
tests/frontend/ frontend tests (jsdom)
vitest.config.ts uses the SDK preset
.github/workflows/ ci.yml and release.yml, calling shared workflows in Termix-Registry
dist/ build output, never commit it
```
## Commands
```bash
npm install
npm run build # termix-plugin build: dist/backend.js, dist/frontend.js, CSS, openapi.json
npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run format # Prettier
npm run format:check
```
## Done checklist
Before you call a change finished:
- [ ] `npm test` passes
- [ ] `npm run typecheck` passes
- [ ] `npm run validate` passes with no warnings you can fix
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
- [ ] User facing changes are in `CHANGELOG.md` under the version they will ship in
## Manifest
```json
{
"$schema": "./node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json",
"id": "hello",
"name": "Hello",
"version": "1.0.0",
"description": "Says hello and keeps a few notes.",
"author": { "name": "You", "url": "https://example.com" },
"license": "Apache-2.0",
"repository": "https://github.com/you/termix-plugin-hello",
"docs": "https://github.com/you/termix-plugin-hello/tree/main/docs",
"category": "Productivity",
"icon": "Hand",
"features": ["A tab from the sidebar", "Notes per user"],
"engine": { "termix": ">=26.10.0", "api": "^1.2" },
"capabilities": ["db:own", "network:serve", "ui:surface"],
"env": [],
"contributes": {
"tabs": [
{
"id": "hello",
"titleKey": "tab.title",
"icon": "Hand",
"openFrom": ["rail"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
```
Unknown fields are refused at every level, so a typo fails validation. The schema at
`node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json` has every field.
| Field | Notes |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id` | Lowercase, starts with a letter, 2 to 40 characters, letters, numbers and dashes. Can't be an official plugin's id. |
| `version` | Semver. Betas are `X.Y.Z-beta.N`. |
| `category` | One of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. |
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
| `features` | Up to 20 short lines shown on the plugin's page. |
| `docs` | An https link to your docs. |
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
| `capabilities` | What your code may do. See the reference at the end. |
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
| `provides`, `requires` | Services you offer and use. |
| `env` | Every environment variable your backend reads. `validate` warns about unlisted `process.env.X`. Prefer settings. |
| `desktop` | `mirror` (default), `local` or `server`: how a linked desktop app treats the plugin. |
| `nativeDependencies` | npm packages with `.node` bindings. Avoid them: community plugins can't count on them. |
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
## Backend
```ts
import type { Router } from "express";
import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
import { notes } from "./tables.js";
import { registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const notesTable = await ctx.db.define(notes);
registerRoutes(ctx.http.router<Router>(), ctx, notesTable);
}
export async function deactivate() {
// Everything registered through ctx is disposed by core.
}
```
Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own files use `.js`.
### What ctx has
| Member | Capability | What it does |
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap |
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
| `currentActor()` | none | The user the current call runs for |
| `disposables.add(fn)` | none | Extra cleanup on disable |
### The actor
In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()` returns it. Background
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
your own port or an nginx change. Before your handler runs, Termix checks sign in, sets the actor, answers
503 if the plugin is off, parses JSON up to 2 MB and catches errors.
```ts
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/hello/notes:
* post:
* summary: Add a note for the signed-in user
* tags:
* - Hello
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* text:
* type: string
* responses:
* 201:
* description: Note added
* 400:
* description: Missing or empty text
*/
router.post("/notes", async (req, res) => {
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
if (!text) return res.status(400).json({ error: "text is required" });
const db = await ctx.db.client<BetterSQLite3Database>();
await db
.insert(notesTable)
.values({ userId: ctx.currentActor() as string, text });
await ctx.db.persist();
res.status(201).json({ success: true });
});
```
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
`dist/openapi.json` and the docs site renders them.
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
`publicRateLimit` raises that for a proxied page that loads its own assets.
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
`conn.isDataUnlocked()` per message.
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
### Errors
Errors from routes, sockets, listeners and timers count against an error budget. Five in a minute and
Termix turns the plugin off (state `failed`). A 4xx you send on purpose doesn't count. Validate input and
send 400s instead of throwing.
### SSH
```ts
const { client, dispose } = await ctx.ssh.connect(hostId, {
purpose: "hello",
profile: "background",
});
try {
// use the ssh2 client
} finally {
dispose();
}
```
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### Events, timers, alerts, services
```ts
ctx.events.on("host.deleted", ({ hostId }) => cleanUp(hostId)); // core topics need events:core
ctx.events.emit("plugin.hello.greeted", { name }); // your own: plugin.<id>.<name>
ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
```
- Alerts go through `ctx.notify.send` (`notify:send`). Never build your own notification system.
- Offer a service: declare it in `provides` with a version and a permission, then
`ctx.services.provide("hello.greetings", impl)`. The name starts with your id. Methods read the caller
from `ctx.currentActor()`.
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that.
### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
`peerDependencies`. Any other npm package is bundled.
### Workers and WebAssembly
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
each worker entry in `package.json`:
```json
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
```
`build` bundles it alone into `dist/workers/engine.js`; start it with
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
`postMessage`.
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
to a `.d.ts` in the plugin.
## Database
Needs `db:own`. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL,
query with [Drizzle](https://orm.drizzle.team/).
```ts
// src/backend/tables.ts
import {
defineTable,
id,
refUser,
text,
timestamp,
} from "@termix-ssh/plugin-sdk/db";
export const notes = defineTable(
"notes",
{
id: id(),
userId: refUser(),
text: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
{ indexes: [{ name: "idx_p_hello_notes_user_id", columns: ["userId"] }] },
);
export const tables = [notes]; // termix-plugin migrations reads this
```
The table is stored as `p_<id with dashes as underscores>_notes`. Index names should use the same prefix.
| Builder | Type |
| ---------------------------------------------- | --------------------------------------------------------------------------- |
| `id()` | Auto increment key |
| `text()` | Any length string. Not indexable |
| `varchar(n)` | String up to `n` (255 default). Indexable |
| `integer()`, `bigint()`, `real()`, `boolean()` | Numbers and booleans |
| `timestamp()` | A time, stored as text |
| `json()` | JSON stored as text. Not indexable |
| `encryptedText()` | Text encrypted with the owning user's key |
| `refUser()` | A user id. Rows are deleted with the user and included in their data export |
| `refHost()` | A host id. Rows are deleted with the host |
Modifiers: `.notNull()`, `.unique()`, `.primaryKey()`, `.default(value)`, `.defaultNow()`.
After any change to `tables.ts`, run `npm run migrations` and read the three new files. Renames and type
changes are refused rather than guessed: write those by hand, for all three databases. A migration may only
touch your own tables. `createTestDb` applies them in tests, so a broken one fails `npm test`.
Query through `const db = await ctx.db.client()`, then `await ctx.db.persist()` after writes
(`persist({ lazy: true })` for frequent low value writes). `ctx.db.dialect` says which database runs.
Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secrets` for secrets,
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
## Frontend
```tsx
import {
useTranslation,
type TabProps,
type TermixApp,
} from "@termix-ssh/plugin-sdk/frontend";
import { EmptyState, PanelShell } from "@termix-ssh/plugin-sdk/ui";
import { Hand } from "lucide-react";
function HelloTab(_props: TabProps) {
const { t } = useTranslation();
return (
<PanelShell icon={<Hand className="size-4" />} title={t("tab.title")}>
<EmptyState title={t("tab.empty")} />
</PanelShell>
);
}
export function activate(app: TermixApp) {
app.registerRailItem({
id: "hello",
icon: Hand,
titleKey: "tab.title",
kind: "tab",
permission: "use",
});
app.registerTab("hello", HelloTab, {
icon: Hand,
titleKey: "tab.title",
singleton: true,
hostless: true,
});
}
```
Every `register*` returns an undo function, and everything is undone on disable. Each component runs in
your plugin's error boundary and Suspense.
| Member | What it does |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `registerRailItem` | A sidebar rail button |
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
| `registerHostAction` | A host button, palette entry or default connect |
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
| `registerHostProtocol` | A connection protocol next to SSH |
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
| `registerSettingsComponent` | A component for a `custom` settings field |
| `registerAction`, `invokeAction` | Actions across plugins |
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
| `registerOnboardingStep` | A first run setup step |
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
| `onSettingsChanged`, `onDispose` | Hooks |
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Looking native
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
exports with props is `ui.api.txt` in the Termix repo
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
- Small type: most text 11 to 13px.
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
`PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
desktop layout.
- Tailwind classes work. `build` compiles the ones you use against Termix's theme.
- Link to your docs with `DocsLink` or `PanelShell`'s `docs` prop, never a hard coded URL.
## Settings
Declare them in `contributes.settings` and Termix draws the form, stores values, encrypts secrets and
checks saves. You don't build settings UI.
- Scopes: `admin` (for everyone), `user` (each person), `host` (a section in the host editor, with an
`enableKey` switch first).
- Types: `boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`, `json`, `custom`.
- Options: `labelKey`, `descriptionKey`, `placeholderKey`, `default`, `options`, `min`, `max`, `requires`,
`group`, `permission`, `hidden`, `component`.
- Read: `ctx.settings.get(key)`, `getUser(userId, key)`, `getHost(hostId, key)`. Write: `set`, `setHost`.
React: `ctx.settings.onChange`. Check: `ctx.settings.onValidate("admin", values => ({ key: "message" }))`.
- Frontend: `useSettings("user")` gives values and `save`.
Prefer settings to environment variables. Use an env var only for something needed before Termix starts.
## Permissions
Declare short names in `contributes.permissions`. Termix registers them as `<id>.<name>`. `defaultRoles`
gives them to the built in `admin` and `user` roles on first install. You can't claim a core group
(`hosts`, `credentials`, `admin`) or another plugin's. Labels are locale keys.
## Translations
Every string goes in `locales/en.json`, nested by area. Manifest keys (`titleKey`, `labelKey`) and `t()`
read it from your own namespace. Unknown keys fall back to Termix's shared strings like `common.save`.
Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go in
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
it. More pages sit next to it with `title` and `order` front matter. Plain Markdown, no MDX. Images go in
`docs/img/`.
Don't write by hand what is generated: features, settings, permissions, capabilities, env vars, services,
the API reference (from `@openapi`) and the changelog. Write short sentences in plain words and name
buttons exactly as the UI does.
## Auth and protocols
Login methods, second factors and SSH auth types need `auth:provide`, which is high risk and reviewed
closely. Declare ids in `contributes.auth` and register them with `ctx.auth.registerLoginMethod`,
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
## Testing
```ts
// vitest.config.ts
import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
```
Backend tests go in `tests/backend/` (Node), frontend tests in `tests/frontend/` (jsdom). Never next to the
source.
Backend, from `@termix-ssh/plugin-sdk/testing`:
```ts
const db = await createTestDb(pluginDir); // in memory SQLite, your migrations, stub users/ssh_data/roles
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities, // calls outside these throw, like the real thing
db: db.database,
router: () => (router = express.Router()),
permissions: ["hello.use"], // leave out to allow everything
});
await activate(mock.ctx);
mock.setActor("user-1");
```
`createMockCtx` also takes `hosts` and `services`. `createFakeContext()` is a looser double that skips the
capability checks. The template's `tests/backend/routes.test.ts` serves the router with Express and calls
it with `fetch`: start from it.
Frontend:
```tsx
import { renderWithApp } from "@termix-ssh/plugin-sdk/testing";
import * as plugin from "../../src/frontend/index.js";
const app = await renderWithApp(plugin, {
api: { get: async () => ({ data: [] }) },
});
expect(app.registered.tabs()).toContain("hello");
```
Test every route, with and without the permission. Test that disable then enable works, and that the plugin
works when an optional dependency is missing.
## Running it on a server
1. You need Node 24 and a Termix server (26.10 or newer) you are an admin on. A local Docker one is fine.
2. In Termix, open Settings, General and turn on Plugin developer mode.
3. Make an API key in Settings, API keys.
4. Run `npx termix-plugin dev --server http://localhost:30001 --key tmx_...` (or set `TERMIX_SERVER_URL`
and `TERMIX_API_KEY`). It builds, installs and reinstalls on every save. Reload the page for frontend
changes. `--once` installs once.
Or `npm run build && npm run pack` and install the `.tmxplug` from Plugins, Install from file. Dev installs
are marked unverified. You can't use the id of a plugin that ships with Termix, and a registry copy of the
same plugin must be uninstalled first.
## Releasing
Plugins use semver. Work happens on a `dev-X.Y.Z` branch made from `main`, named after the version it will
ship as. `main` only changes when a stable release merges a dev branch into it.
`CHANGELOG.md`, newest first, no `## Unreleased` section and no dates:
```md
# Changelog
## 1.1.0
### Added
- A wave button in the terminal toolbar
### Fixed
- Notes keep their order after a restart
```
Sections: `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security`.
`termix-plugin changelog --version 1.1.0` prints one version's notes.
Run the Release workflow by hand from the dev branch on GitHub:
| Choice | What it does |
| --------- | --------------------------------------------------------------------------------- |
| stable | Tests, builds and publishes `X.Y.Z`, merges the branch into `main` and deletes it |
| beta | Publishes `X.Y.Z-beta.N`, N counts up on its own |
| overwrite | Releases the manifest version again from this commit |
| dry-run | Builds and packs, publishes nothing |
No secrets are needed. The workflow records GitHub build provenance for the `.tmxplug`, and the run summary
shows the `sha256` for a registry submission. If a dev branch changes `.github/workflows`, add a
`TERMIX_PAT` secret (a token with `repo` and `workflow` scopes). To also sign with your own key, set
`TERMIX_PLUGIN_SIGNING_KEY`. Keep `.github/workflows/release.yml` as the template has it: the community
registry checks provenance from it.
## Submitting to the community registry
Before you submit:
- The repo is public with an open source license, made from the template or using its `release.yml`.
- `npm test`, `npm run typecheck`, `npm run validate` and `npm run build` pass.
- The `id` isn't used by an official plugin or another submission.
- `name`, `description`, `author`, `repository` (your repo) and `docs` (or a README that explains use) are
set.
Then release a stable or beta version and fork https://github.com/Termix-SSH/Termix-Registry. Add
`community/plugins/<id>.json`:
```json
{
"$schema": "../../community-submission-schema.json",
"id": "my-plugin",
"repository": "https://github.com/you/termix-plugin-my-plugin",
"maintainers": ["your-github-username"],
"versions": [
{ "version": "1.0.0", "sha256": "<from the release run summary>" }
]
}
```
Open a pull request. The Check submission job downloads the `v<version>` release and checks the `sha256`,
that the manifest inside has your id, version and repo, that it has build provenance from the release
workflow, and that you are a maintainer. Then a person reviews the source at the tag and merges it. The
registry signs it with the community key and lists it a few minutes later.
Reviewers check that:
- The source does what the name and description say, and nothing else.
- Nothing is obfuscated or minified, and no code is downloaded and run at runtime.
- There are no network calls, tracking or telemetry the user didn't ask for.
- Capabilities and permissions are the fewest needed.
- `package.json` scripts and dependencies don't fetch or run anything unexpected at install or build.
- Secrets are only used for what the plugin is for.
- The name and icon don't pretend to be an official plugin.
Every version needs its own PR that adds it to the top of `versions`. Overwriting a reviewed release drops
it from the registry until a new version is submitted. Admins see the plugin once they turn on the community
registry in Plugins, Registries.
To run your own registry instead, see https://docs.termix.site/develop/registries.
## termix-plugin CLI
| Command | What it does |
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
## SDK entry points
| Import | What it is |
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
## Common mistakes
- Tests fail after `npm install`: usually a missing devDependency (`@testing-library/dom`, `jsdom`,
`better-sqlite3`) or a newer TypeScript major pulled in by another package. Pin `typescript` to the
template's range.
- `hello-world`, `hello_world` or `Hello World` left over from the template in `package.json`, locales,
index names, API paths, tab ids or tests.
- Changed `tables.ts` without running `npm run migrations`, or kept the template's `migrations/` folder
after renaming the id. Delete it and run `npm run migrations` once on a fresh plugin.
- A rail item or tab id that isn't declared in `contributes`.
- A string typed straight into JSX instead of `t()`.
- Forgetting `await ctx.db.persist()` after a write.
- Frontend changes not showing during `dev`: reload the page.
- An id clash with an official plugin: dev installs and registry submissions are refused.
## Where to look next
- Docs: https://docs.termix.site/develop
- Official plugins are all open source and built the same way. Plugin-Workspaces is a small one to start
from, Plugin-Docker runs commands on hosts, Plugin-Snippets offers a service, Plugin-Automations uses
services, Plugin-SSO and Plugin-TOTP add sign in, Plugin-Vault adds an SSH auth type and
Plugin-Remote-Desktop adds a protocol. All at https://github.com/Termix-SSH.
- Help: the Discord at https://discord.gg/jVQGdvHDrf. SDK bugs go in
https://github.com/Termix-SSH/Termix/issues.
+3 -3
View File
@@ -1,6 +1,6 @@
<div align="center">
<img src="https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.svg" width="120" height="120" alt="Termix Logo" />
<img src="https://termix.site/img/logo.svg" width="120" alt="Termix Logo" />
<h1>Termix Plugin SDK</h1>
@@ -50,7 +50,7 @@ in place of the flags. Reload the page to pick up frontend changes.
## 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.
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). Add as much detail as you can, in English if possible.
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
@@ -58,4 +58,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
## License
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
Apache License 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE).
+54 -3
View File
@@ -52,6 +52,43 @@ export function externalRequireInterop(externals) {
};
}
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
// so it ships in backend.js or a worker with no separate file to find.
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
const BINARY_LOADERS = Object.fromEntries(
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
);
const REQUIRE_BANNER =
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
/**
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
* bundles each entry on its own into dist/workers/<name>.js, for
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
* external: core's import fallback only runs on the main thread, so a worker
* can't rely on host packages and must not import the SDK.
*/
export function readWorkerEntries(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const workers = pkg.termix?.workers ?? {};
return Object.entries(workers).map(([name, entry]) => {
if (!/^[a-z0-9-]+$/.test(name)) {
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
}
const file = path.resolve(cwd, String(entry));
if (!fs.existsSync(file)) {
throw new Error(
`${pluginId}: worker "${name}" entry ${entry} is missing`,
);
}
return { name, file };
});
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
@@ -216,13 +253,27 @@ export async function build({ cwd }) {
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: {
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
},
banner: { js: REQUIRE_BANNER },
});
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
entryPoints: [worker.file],
outfile: path.join(outDir, "workers", `${worker.name}.js`),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
loader: BINARY_LOADERS,
banner: { js: REQUIRE_BANNER },
});
}
// Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM.
fs.writeFileSync(
+3
View File
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.5",
"version": "1.0.11",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -103,6 +103,7 @@
"tsconfig.plugin.json",
"README.md",
"CHANGELOG.md",
"PLUGIN-GUIDE.md",
"client.d.ts"
],
"scripts": {
+50 -3
View File
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
* (multer, a raw body for signature checking, a proxied stream).
*/
rawBody?: boolean;
/**
* Requests per minute one address may make to this plugin's public routes.
* Defaults to 120, which a proxied page loading its own scripts and images
* can pass on its own. Capped at 10000, and an admin's
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
*/
publicRateLimit?: number;
}
/**
@@ -1457,6 +1464,20 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
ttlSeconds?: number;
}
export interface PluginUserToken {
/** Send as `Authorization: Bearer <token>` to core's own API. */
token: string;
/** ISO time the session ends. */
expiresAt: string;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
@@ -1518,6 +1539,26 @@ export interface PluginAuth {
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
/**
* A short-lived session for an existing user, for calling core's own HTTP
* API as them from work with no request behind it, like a scheduled task
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
* users:impersonate, not auth:provide, and every call is audited.
*
* The session is marked with the plugin and purpose. It cannot change the
* user's password, second factors, API keys or linked logins, so it can
* never outlive itself.
*/
issueUserToken: (
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
}
/** What a plugin may learn about a user. Needs users:read. */
export interface PluginUsers {
/** The user's UI language, like "zh_CN", or null when they never chose one. */
getLocale: (userId: string) => Promise<string | null>;
}
export interface PluginOpenIsolatedWindowRequest {
@@ -2006,8 +2047,13 @@ export interface PluginContext {
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
/**
* Login methods, second factors and SSH auth types. Needs auth:provide,
* except issueUserToken, which needs users:impersonate.
*/
readonly auth: PluginAuth;
/** What a plugin may learn about a user. Needs users:read. */
readonly users: PluginUsers;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -2032,8 +2078,9 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* on services.get or secrets.getShared are the only ways a plugin can act
* as a user it names.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+12 -2
View File
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
continue;
}
if (char === ";") {
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
return statements;
}
/** False for a chunk that is only comments, which a driver refuses to run. */
function hasSql(chunk: string): boolean {
return (
chunk
.replace(/--[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim() !== ""
);
}
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
+1 -1
View File
@@ -36,7 +36,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.2.0";
export const PLUGIN_API_VERSION = "1.3.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+33 -1
View File
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
}
export interface FakePluginContext {
@@ -574,6 +578,7 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1140,6 +1145,18 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
},
users: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -1495,6 +1515,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1786,6 +1807,17 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
},
users: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
desktop: {
+24
View File
@@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null {
}
}
/**
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
* so code that imports a module compiles it the same way under vitest.
*/
export function wasmBinaryPlugin() {
return {
name: "termix-wasm-binary",
enforce: "pre" as const,
load(id: string) {
const file = id.split("?")[0];
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
return null;
}
const base64 = fs.readFileSync(file).toString("base64");
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
},
};
}
export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */
alias?: Record<string, string>;
@@ -96,7 +115,10 @@ export function pluginVitestConfig(
// unexpectedly". Threads don't.
const pool = "threads" as const;
const plugins = [wasmBinaryPlugin()];
return {
plugins,
resolve: { alias },
test: {
globals: true,
@@ -104,6 +126,7 @@ export function pluginVitestConfig(
pool,
projects: [
{
plugins,
resolve: { alias },
test: {
name: `${pluginId}:backend`,
@@ -116,6 +139,7 @@ export function pluginVitestConfig(
},
},
{
plugins,
resolve: { alias },
test: {
name: `${pluginId}:frontend`,
+1 -1
View File
@@ -95,7 +95,7 @@ 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>>
-5
View File
@@ -1,5 +0,0 @@
{
"only-arches": ["x86_64", "aarch64"],
"skip-icons-check": false,
"skip-appstream-check": false
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#141513"/>
<rect x="227" y="244" width="570" height="92" rx="46" fill="#ffffff"/>
<path d="M474 336H550V786A38 38 0 0 1 474 786V572.2L542 545V447L474 419.8ZM498 647.5V761.5H550V647.5Z" fill="#ffffff" fill-rule="evenodd"/>
<path d="M306 387.2L510 468.8V523.2L306 604.8V547.5L435.5 496L306 444.5Z" fill="#f59145"/>
<rect x="530.5" y="679.5" width="213" height="50" fill="#f59145"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<path d="M0 0 C1.24605143 -0.00320648 2.49210286 -0.00641296 3.77591342 -0.0097166 C7.20551324 -0.01654213 10.63489336 -0.01454336 14.06448046 -0.00866088 C17.80678204 -0.00446316 21.54906441 -0.01190796 25.29136121 -0.01781338 C31.84084796 -0.02655123 38.39030342 -0.02789611 44.93979454 -0.02431011 C54.67961151 -0.01899148 64.41939833 -0.02511011 74.15921202 -0.03339304 C92.56926142 -0.04861937 110.97929706 -0.05033948 129.38935144 -0.04787002 C143.60890976 -0.04603597 157.82846357 -0.0477993 172.04802132 -0.05209446 C174.09235779 -0.05269687 176.13669427 -0.05329806 178.18103074 -0.05389806 C181.26182901 -0.05480534 184.34262728 -0.05571401 187.42342555 -0.05663041 C216.06152484 -0.06505806 244.69962243 -0.06939698 273.33772278 -0.06522369 C274.95656045 -0.06498913 274.95656045 -0.06498913 276.60810192 -0.06474983 C282.07778518 -0.06394407 287.54746842 -0.0630983 293.01715167 -0.06223653 C294.64653437 -0.06197989 294.64653437 -0.06197989 296.30883388 -0.06171808 C298.50550538 -0.06137142 300.70217687 -0.06102289 302.89884837 -0.06067249 C337.07170733 -0.05531644 371.24454049 -0.06324318 405.41739464 -0.08170605 C443.83052411 -0.10243565 482.24363759 -0.11314686 520.65677297 -0.10913056 C524.75506425 -0.10873523 528.85335552 -0.10837368 532.9516468 -0.10803318 C533.96071349 -0.10794245 534.96978018 -0.10785172 536.00942464 -0.10775824 C551.22334929 -0.10659366 566.43725776 -0.11323001 581.65117836 -0.1238451 C598.96103916 -0.13591042 616.2708644 -0.13744077 633.58072574 -0.12505493 C643.25916158 -0.11843905 652.93751231 -0.12002798 662.6159413 -0.13364345 C669.09234848 -0.1419165 675.56869223 -0.13915349 682.04509432 -0.12766818 C685.72751758 -0.12143989 689.40977538 -0.11973724 693.09218879 -0.13153151 C742.29620511 -0.27803261 783.72404367 14.44275388 819.33878136 49.25447559 C838.15454949 68.05987805 851.6807596 92.15656693 859.15128136 117.62947559 C859.50259117 118.81223072 859.50259117 118.81223072 859.86099815 120.01887989 C864.84716349 137.93439184 865.34131204 155.41560974 865.28668402 173.91294083 C865.27899556 177.65690687 865.28888432 181.40083665 865.29641402 185.14479738 C865.3071966 191.69097825 865.30695291 198.23708474 865.29927254 204.78326893 C865.2878824 214.51804038 865.2937484 224.25274959 865.30289746 233.98752068 C865.31866453 251.37833898 865.3173375 268.76912415 865.30866072 286.15994558 C865.30110466 301.37985494 865.29914832 316.59975219 865.30272865 331.81966305 C865.30295781 332.83047421 865.30318696 333.84128537 865.30342306 334.88272717 C865.30435713 338.98627537 865.30531233 343.08982357 865.30630235 347.19337176 C865.3154731 385.66415573 865.30716901 424.13491865 865.29101166 462.60569937 C865.27677663 496.7590625 865.27804855 530.91239206 865.29239464 565.06575489 C865.30847087 603.46608751 865.31482155 641.86640153 865.30555785 680.26673704 C865.30459574 684.36298215 865.30365722 688.45922726 865.30272865 692.55547237 C865.3023728 694.06829454 865.3023728 694.06829454 865.30200977 695.61167876 C865.29860234 710.81857886 865.3025789 726.02546547 865.31016731 741.2323637 C865.31875621 758.53008151 865.31657695 775.82776109 865.30032508 793.12547415 C865.29153676 802.79747308 865.2909114 812.46938917 865.3022636 822.1413859 C865.30901854 828.61208821 865.30475417 835.08272447 865.2917493 841.55341703 C865.28465281 845.23332826 865.2820887 848.9130756 865.29296724 852.59298133 C865.3442355 871.30113003 864.89727324 889.46647244 859.15128136 907.44197559 C858.89524132 908.2652837 858.63920128 909.0885918 858.37540245 909.93684864 C855.79685812 917.9856683 852.58102984 925.65348896 848.90128136 933.25447559 C848.61591515 933.86033497 848.33054893 934.46619434 848.03653526 935.09041309 C830.37472338 972.22703729 796.06237029 1001.64914703 757.52628136 1015.44197559 C741.01943895 1021.1452558 724.41526408 1024.57505987 706.89996243 1024.5117445 C705.65228729 1024.51525057 704.40461215 1024.51875665 703.11912867 1024.52236897 C699.68034267 1024.53174518 696.24166373 1024.52885318 692.80287276 1024.52381342 C689.05227316 1024.52052693 685.3016955 1024.52884577 681.55110306 1024.53563178 C674.98544107 1024.54591004 668.41980856 1024.54877936 661.85413933 1024.54671955 C652.09014292 1024.54366388 642.32617902 1024.55200613 632.56218895 1024.5624688 C614.10585117 1024.58179372 595.64952453 1024.58743648 577.19317783 1024.58858292 C562.93642719 1024.5895027 548.6796809 1024.59362298 534.42293167 1024.59998417 C530.31618419 1024.60177932 526.2094367 1024.60355895 522.10268921 1024.60533392 C521.08167553 1024.60577602 520.06066186 1024.60621812 519.00870837 1024.60667361 C490.29523693 1024.61897939 461.581767 1024.62687502 432.86829281 1024.62598705 C431.78608639 1024.6259559 430.70387998 1024.62592474 429.58887942 1024.62589264 C424.10406817 1024.62572643 418.61925692 1024.62552835 413.13444567 1024.62532322 C412.04518472 1024.62528333 410.95592376 1024.62524344 409.8336549 1024.62520234 C407.63090343 1024.62512147 405.42815196 1024.62503787 403.22540049 1024.62495156 C368.96540695 10Line truncated
<path d="M0 0 C2.3607826 -0.00533069 4.7215618 -0.01239937 7.08233458 -0.02104706 C12.86781777 -0.03768809 18.65308544 -0.03586118 24.43857937 -0.02603391 C31.37506207 -0.01528955 38.31145513 -0.02784201 45.24792682 -0.04094901 C58.83711965 -0.06583655 72.42624267 -0.0677834 86.01545164 -0.0596749 C97.90842776 -0.05259347 109.80137684 -0.05255306 121.6943531 -0.05953789 C123.29160454 -0.06044923 124.88885599 -0.06135999 126.48610743 -0.06227018 C128.89321227 -0.06365335 131.3003171 -0.06504445 133.70742193 -0.06645631 C156.07605562 -0.07943052 178.44468029 -0.0845595 200.81331539 -0.07292271 C202.07720574 -0.07226575 202.07720574 -0.07226575 203.36662924 -0.07159552 C210.20845318 -0.06798894 217.05027701 -0.06415405 223.89210082 -0.06030459 C250.57968088 -0.04545695 277.26715859 -0.05423912 303.95472717 -0.08226013 C333.96091556 -0.11370558 363.96704337 -0.12865264 393.97324854 -0.11846012 C397.17413714 -0.11742339 400.37502576 -0.11643616 403.57591438 -0.11547661 C404.75806397 -0.11510328 404.75806397 -0.11510328 405.96409537 -0.11472241 C417.84673069 -0.11128624 429.72931241 -0.12057354 441.61193657 -0.13624763 C455.12861173 -0.15403651 468.64515667 -0.15411882 482.16182631 -0.13089598 C489.05584211 -0.11951551 495.94963463 -0.11592963 502.84363937 -0.13519669 C509.15952167 -0.15202846 515.47494573 -0.1456129 521.79079849 -0.1209799 C524.07057599 -0.11610521 526.35039063 -0.11958862 528.63013696 -0.13246734 C545.55919757 -0.22117063 559.49258132 1.40958132 573.70204163 11.5146637 C574.65143616 12.18948792 574.65143616 12.18948792 575.62001038 12.87794495 C584.56469704 19.88757798 591.37473569 31.41173592 593.70204163 42.5146637 C593.95009484 46.21967671 593.9332442 49.92717174 593.95204163 53.6396637 C593.98297913 55.13239807 593.98297913 55.13239807 594.01454163 56.6552887 C594.09018739 71.14145219 588.46971331 82.75864333 578.63954163 93.3271637 C572.7611904 98.9075711 566.35707715 102.79152692 558.70204163 105.5146637 C557.98319668 105.77360066 557.26435173 106.03253763 556.5237236 106.29932117 C545.67592079 109.56985516 534.52679546 108.91143924 523.30848694 108.85523987 C520.90407196 108.85431984 518.49965616 108.85472308 516.09524155 108.85633564 C509.68138016 108.85701034 503.26768815 108.83967715 496.85386658 108.81850433 C490.82587678 108.80136501 484.79792729 108.80061201 478.76991272 108.79847717 C461.16393343 108.7827621 443.55798536 108.74087841 425.95204163 108.7021637 C386.59954163 108.6402887 347.24704163 108.5784137 306.70204163 108.5146637 C306.70204163 227.6446637 306.70204163 346.7746637 306.70204163 469.5146637 C286.90204163 469.5146637 267.10204163 469.5146637 246.70204163 469.5146637 C246.70204163 513.4046637 246.70204163 557.2946637 246.70204163 602.5146637 C266.50204163 602.5146637 286.30204163 602.5146637 306.70204163 602.5146637 C306.79485413 611.0843512 306.79485413 611.0843512 306.88954163 619.8271637 C306.91685364 621.60647278 306.94416565 623.38578186 306.9723053 625.2190094 C307.11177013 640.67747815 304.32285969 652.54241746 293.63954163 664.2646637 C283.35164203 674.1494599 271.29383334 675.99381057 257.74501038 675.7881012 C245.19699202 675.32053069 236.72499202 668.99701836 228.38954163 660.0771637 C220.57277107 650.9239274 218.39160693 641.68285062 218.46153259 629.87820435 C218.45896932 628.28889812 218.45896932 628.28889812 218.45635426 626.6674847 C218.45306486 623.123718 218.46405419 619.58014142 218.47499084 616.03639221 C218.47555508 613.49352015 218.47523817 610.95064775 218.47410583 608.40777588 C218.47365067 602.93649447 218.47931674 597.46527407 218.49058151 591.9940052 C218.50685769 584.08354929 218.51204668 576.17311643 218.5145575 568.26264559 C218.51893128 555.42744908 218.53223178 542.59227803 218.55116272 529.75709534 C218.56952972 517.29127632 218.58368221 504.82546422 218.59217834 492.3596344 C218.59270379 491.5908286 218.59322924 490.82202281 218.59377061 490.02991987 C218.59638074 486.17292397 218.59890815 482.31592802 218.60139441 478.45893204 C218.62211705 446.47749075 218.65733395 414.49608076 218.70204163 382.5146637 C224.61760486 380.08952596 230.53376566 377.66585017 236.45030212 375.24308777 C238.45381022 374.42248424 240.45719208 373.60157242 242.46043396 372.78031921 C294.33562193 351.5146637 294.33562193 351.5146637 297.70204163 351.5146637 C297.70204163 313.8946637 297.70204163 276.2746637 297.70204163 237.5146637 C293.24704163 236.0296637 293.24704163 236.0296637 288.70204163 234.5146637 C286.21106492 233.56633242 283.72835319 232.59599737 281.25453186 231.60377502 C280.55959405 231.32587222 279.86465624 231.04796942 279.14865971 230.76164532 C276.93500856 229.87574275 274.72279495 228.98631453 272.51063538 228.09669495 C271.12991114 227.54352131 269.74909265 226.9905829 268.36817932 226.43788147 C263.29082801 224.40381573 258.21529078 222.36522413 253.13954163 220.3271637 C241.77516663 215.7690387 230.41079163 211.2109137 218.70204163 206.5146637 C218.70204163 174.1746637 218.70204163 141.8346637 218.70204163 108.5146637 C211.4392218 1Line truncated
<path d="M0 0 C5.86817508 1.95605836 11.59073119 3.95365747 17.30737305 6.27001953 C18.09306325 6.58584142 18.87875345 6.9016633 19.68825245 7.22705555 C22.29709699 8.27645462 24.90439611 9.32963844 27.51171875 10.3828125 C29.39488199 11.14115547 31.2781448 11.89925122 33.16149902 12.65711975 C37.21264727 14.28789145 41.26312379 15.92031644 45.31307983 17.55404663 C51.7542863 20.15236465 58.19696081 22.74702807 64.63989258 25.34106445 C71.41130904 28.06755974 78.18261714 30.79432275 84.95341492 33.52235413 C108.15087431 42.86883922 131.35537268 52.19778488 154.5609436 61.52410889 C159.994401 63.70786608 165.42764165 65.89216193 170.86079502 68.07667542 C193.23239581 77.07158187 215.60890835 86.05370489 238 95 C238 116.12 238 137.24 238 159 C232.01359375 161.39185547 232.01359375 161.39185547 225.90625 163.83203125 C206.57998543 171.55973546 187.26934996 179.32631616 167.95794678 187.09106445 C161.36560101 189.74165676 154.77289802 192.39135988 148.18017578 195.04101562 C126.79552102 203.63604381 105.41240644 212.23486217 84.03399658 220.84541321 C75.73587703 224.18760199 67.43708216 227.52811242 59.13812637 230.86822414 C52.33470621 233.60660835 45.53222385 236.34730609 38.73039818 239.08964825 C34.51543173 240.78839672 30.29959748 242.48498283 26.08351517 244.1809597 C24.12464123 244.96946815 22.16613491 245.75889055 20.20801926 246.54928017 C17.53921709 247.62640467 14.86914587 248.70032007 12.19873047 249.7734375 C11.02965778 250.24663689 11.02965778 250.24663689 9.83696747 250.72939587 C6.56202144 252.04197447 3.43106937 253.14223266 0 254 C0 231.56 0 209.12 0 186 C21.85641702 177.6267728 43.73124762 169.30784563 65.63814926 161.06789398 C71.62769516 158.81489337 77.61678737 156.56068672 83.60580635 154.30628586 C85.09797285 153.74460399 86.5901539 153.18296077 88.08234978 152.62135696 C97.85972778 148.94125789 107.63197621 145.24805417 117.3984375 141.5390625 C119.52367608 140.73273275 121.64892001 139.92641707 123.77416992 139.12011719 C127.62723328 137.65810939 131.4786996 136.19199339 135.3293457 134.72363281 C137.01507528 134.08382248 138.70086226 133.44416335 140.38671875 132.8046875 C141.51894363 132.37079208 141.51894363 132.37079208 142.67404175 131.9281311 C147.77255889 130 147.77255889 130 150 130 C149.67 128.02 149.34 126.04 149 124 C147.88878784 123.58995728 147.88878784 123.58995728 146.75512695 123.17163086 C117.43275623 112.3398187 88.19328622 101.28796181 58.94955444 90.24617195 C50.58387921 87.08758433 42.21738212 83.93117504 33.85093689 80.77462769 C22.56697288 76.5172614 11.28352836 72.25852172 0 68 C0 45.56 0 23.12 0 0 Z " fill="#F39044" transform="translate(272,342)"/>
<path d="M0 0 C81.84 0 163.68 0 248 0 C248 19.47 248 38.94 248 59 C166.16 59 84.32 59 0 59 C0 39.53 0 20.06 0 0 Z " fill="#F39044" transform="translate(533,681)"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" rx="225" fill="#141513"/>
<rect x="227" y="244" width="570" height="92" rx="46" fill="#ffffff"/>
<path d="M474 336H550V786A38 38 0 0 1 474 786V572.2L542 545V447L474 419.8ZM498 647.5V761.5H550V647.5Z" fill="#ffffff" fill-rule="evenodd"/>
<path d="M306 387.2L510 468.8V523.2L306 604.8V547.5L435.5 496L306 444.5Z" fill="#f59145"/>
<rect x="530.5" y="679.5" width="213" height="50" fill="#f59145"/>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
* pinned sha256 and unpacked.
*
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
* folder of plugin repos like ../Plugins. A plugin there whose sources
* changed since its last build is rebuilt first with this checkout's SDK, so
* SDK edits apply without publishing, then any built one (has
* dist/) is copied in place of its pin, or added when it has no pin.
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* packages/plugin-sdk/PLUGIN-GUIDE.md ships with the SDK and is what plugin
* authors and their AI tools read first. Fails when it misses a CLI command,
* a capability or an entry point, or when its header names an older SDK or
* plugin API version, so the guide is touched with every SDK release.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SDK = path.join(ROOT, "packages", "plugin-sdk");
// Exports that are plumbing, not something a plugin author imports.
const INTERNAL_EXPORTS = new Set([
"./testing/setup",
"./testing-host",
"./tsconfig.plugin.json",
"./schema/manifest.schema.json",
"./theme.css",
]);
function cliCommands(source) {
const block = source.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
if (!block) return [];
return [...block[1].matchAll(/^ {2}([a-z-]+)[:,]/gm)].map((m) => m[1]);
}
function capabilityIds(source) {
return [
...new Set(
[...source.matchAll(/entry\("([a-z-]+:[a-z-]+)"/g)].map((m) => m[1]),
),
];
}
function entryPoints(pkg) {
return Object.keys(pkg.exports ?? {})
.filter((key) => key !== "." && !INTERNAL_EXPORTS.has(key))
.map((key) => `@termix-ssh/plugin-sdk/${key.slice(2)}`);
}
function apiVersion(source) {
return source.match(/PLUGIN_API_VERSION = "([^"]+)"/)?.[1] ?? null;
}
function findProblems({
guide,
commands,
capabilities,
entries,
sdkVersion,
api,
}) {
const problems = [];
for (const command of commands) {
// `sign <file>` counts as well as `sign`.
if (!new RegExp(`\`${command}[\` ]`).test(guide))
problems.push(`CLI command "${command}" is not covered`);
}
for (const id of capabilities) {
if (!guide.includes(`\`${id}\``))
problems.push(`capability "${id}" is not covered`);
}
for (const entry of entries) {
if (!guide.includes(`\`${entry}\``))
problems.push(`entry point "${entry}" is not covered`);
}
const header = guide.match(/^SDK (\S+) \| Plugin API (\S+)$/m);
if (!header) {
problems.push('missing the "SDK x.y.z | Plugin API x.y.z" header line');
} else {
if (header[1] !== sdkVersion) {
problems.push(`header says SDK ${header[1]}, package is ${sdkVersion}`);
}
if (api && header[2] !== api) {
problems.push(`header says Plugin API ${header[2]}, SDK has ${api}`);
}
}
return problems;
}
function main() {
const read = (file) => fs.readFileSync(path.join(SDK, file), "utf8");
const pkg = JSON.parse(read("package.json"));
const problems = findProblems({
guide: read("PLUGIN-GUIDE.md"),
commands: cliCommands(read("cli/index.mjs")),
capabilities: capabilityIds(read("src/capabilities.ts")),
entries: entryPoints(pkg),
sdkVersion: pkg.version,
api: apiVersion(read("src/manifest.ts")),
});
if (!pkg.files?.includes("PLUGIN-GUIDE.md")) {
problems.push(
'"PLUGIN-GUIDE.md" is not in the SDK package.json files list',
);
}
if (problems.length > 0) {
console.error(
"check-sdk-guide: update packages/plugin-sdk/PLUGIN-GUIDE.md",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
}
if (require.main === module) main();
module.exports = {
cliCommands,
capabilityIds,
entryPoints,
apiVersion,
findProblems,
};
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { cliCommands, capabilityIds, entryPoints, apiVersion, findProblems } =
require("./check-sdk-guide.cjs") as {
cliCommands: (source: string) => string[];
capabilityIds: (source: string) => string[];
entryPoints: (pkg: { exports?: Record<string, unknown> }) => string[];
apiVersion: (source: string) => string | null;
findProblems: (input: {
guide: string;
commands: string[];
capabilities: string[];
entries: string[];
sdkVersion: string;
api: string | null;
}) => string[];
};
const GUIDE = [
"# Guide",
"",
"SDK 1.2.3 | Plugin API 1.4.0",
"",
"| `build` | builds |",
"| `sign <file>` | signs |",
"| `db:own` | tables |",
"| `@termix-ssh/plugin-sdk/db` | tables |",
].join("\n");
const input = {
guide: GUIDE,
commands: ["build", "sign"],
capabilities: ["db:own"],
entries: ["@termix-ssh/plugin-sdk/db"],
sdkVersion: "1.2.3",
api: "1.4.0",
};
describe("readers", () => {
it("reads command names from the COMMANDS map", () => {
const source = [
"const COMMANDS = {",
" build,",
" patch: async () => {},",
" test: async (options) => {",
" return run(options);",
" },",
" verify,",
"};",
].join("\n");
expect(cliCommands(source)).toEqual(["build", "patch", "test", "verify"]);
});
it("reads capability ids once each", () => {
const source =
'entry("db:own", "low"),\nentry("ssh:connect", "high"),\nentry("db:own", "low")';
expect(capabilityIds(source)).toEqual(["db:own", "ssh:connect"]);
});
it("skips the root and internal exports", () => {
expect(
entryPoints({
exports: {
".": {},
"./db": {},
"./testing/setup": {},
"./theme.css": "",
},
}),
).toEqual(["@termix-ssh/plugin-sdk/db"]);
});
it("reads the plugin API version", () => {
expect(apiVersion('export const PLUGIN_API_VERSION = "1.2.0";')).toBe(
"1.2.0",
);
});
});
describe("findProblems", () => {
it("passes a guide that covers everything", () => {
expect(findProblems(input)).toEqual([]);
});
it("names what is missing", () => {
expect(
findProblems({
...input,
commands: ["build", "dev"],
capabilities: ["db:own", "kv:own"],
entries: ["@termix-ssh/plugin-sdk/ui"],
}),
).toEqual([
'CLI command "dev" is not covered',
'capability "kv:own" is not covered',
'entry point "@termix-ssh/plugin-sdk/ui" is not covered',
]);
});
it("fails when the header is behind the SDK", () => {
expect(
findProblems({ ...input, sdkVersion: "1.2.4", api: "1.5.0" }),
).toEqual([
"header says SDK 1.2.3, package is 1.2.4",
"header says Plugin API 1.4.0, SDK has 1.5.0",
]);
expect(findProblems({ ...input, guide: "no header" })).toContain(
'missing the "SDK x.y.z | Plugin API x.y.z" header line',
);
});
});
-126
View File
@@ -1,126 +0,0 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
function isSourceFile(file) {
return file.path === `/${SOURCE_FILE}`;
}
async function resolveFileIds(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const ids = files.filter(isSourceFile).map((f) => f.id);
if (ids.length === 0)
throw new Error(`no "${SOURCE_FILE}" source files found in project`);
return ids;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileIds = await resolveFileIds(projectId);
console.log(
`Pre-translating project ${projectId}, ${fileIds.length} files, ${targetLanguageIds.length} languages`,
);
// Translation memory first so strings that moved between files keep their
// existing translations, then machine translation for whatever is left.
const passes = [
{ method: "tm", translateUntranslatedOnly: true },
{ method: "mt", engineId: ENGINE_ID, scope: "untranslated" },
];
for (const pass of passes) {
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{ languageIds: targetLanguageIds, fileIds, ...pass },
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(
`${pass.method} pre-translation finished (${result.progress}%)`,
);
}
}
module.exports = {
resolveProjectId,
resolveFileIds,
isSourceFile,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
isSourceFile: (file: { path?: string }) => boolean;
};
describe("isSourceFile", () => {
it("matches the core source file", () => {
expect(isSourceFile({ path: "/en.json" })).toBe(true);
});
it("skips anything else", () => {
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
false,
);
expect(isSourceFile({})).toBe(false);
});
});
+2
View File
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+4 -5
View File
@@ -3,7 +3,7 @@
* and options.
*
* Every target builds the SDK and stages plugins (local builds from
* ../Termix-Plugins when it exists), then:
* ../Plugins when it exists), then:
*
* - web: compiles the backend in watch mode, starts it, then starts Vite. A
* backend or SDK change restarts the backend. A plugin change rebuilds that
@@ -14,7 +14,8 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it.
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
*/
import fs from "node:fs";
@@ -652,9 +653,7 @@ async function main() {
const pluginsDir = options.local
? path.resolve(
root,
options.pluginsDir ??
process.env.TERMIX_LOCAL_PLUGINS ??
"../Termix-Plugins",
options.pluginsDir ?? process.env.TERMIX_LOCAL_PLUGINS ?? "../Plugins",
)
: null;
const localPlugins =
+372
View File
@@ -0,0 +1,372 @@
import { createRequire } from "node:module";
import path from "node:path";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const updates = require("../electron/auto-updater.cjs") as {
channelFile: (channel: string, platform: string, arch: string) => string;
getInstallKind: (input: Record<string, unknown>) => string;
resolveRelease: (
releases: unknown,
opts: { currentVersion: string; platform: string; arch: string },
) => {
tag: string;
version: string;
channel: string;
feedUrl: string;
releaseUrl: string;
} | null;
createUpdater: (deps: Record<string, unknown>) => {
getState: () => Record<string, unknown>;
check: () => Promise<Record<string, unknown>>;
download: () => Promise<Record<string, unknown>>;
install: () => boolean;
setMode: (mode: string) => Record<string, unknown>;
};
};
function release(tag: string, assets: string[], extra = {}) {
return {
tag_name: tag,
draft: false,
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/${tag}`,
assets: assets.map((name) => ({ name })),
...extra,
};
}
const ALL = [
"latest.yml",
"latest-mac.yml",
"latest-linux.yml",
"latest-linux-arm64.yml",
"latest-linux-arm.yml",
"beta.yml",
"beta-mac.yml",
"beta-linux.yml",
];
const win = { platform: "win32", arch: "x64" };
describe("channelFile", () => {
it("matches the names electron-builder writes", () => {
expect(updates.channelFile("latest", "win32", "x64")).toBe("latest.yml");
expect(updates.channelFile("latest", "darwin", "arm64")).toBe(
"latest-mac.yml",
);
expect(updates.channelFile("latest", "linux", "x64")).toBe(
"latest-linux.yml",
);
expect(updates.channelFile("beta", "linux", "arm64")).toBe(
"beta-linux-arm64.yml",
);
expect(updates.channelFile("latest", "linux", "arm")).toBe(
"latest-linux-arm.yml",
);
});
});
describe("resolveRelease", () => {
it("picks the newest stable release and points the feed at its tag", () => {
const found = updates.resolveRelease(
[release("v26.10.2", ALL), release("v26.10.3", ALL)],
{ currentVersion: "26.10.1", ...win },
);
expect(found).toMatchObject({
tag: "v26.10.3",
version: "26.10.3",
channel: "latest",
feedUrl:
"https://github.com/Termix-SSH/Termix/releases/download/v26.10.3",
});
});
it("ignores SDK, 2.9.x patch and draft releases", () => {
expect(
updates.resolveRelease(
[
release("sdk-v1.0.9", ALL),
release("release-2.9.4-tag", ALL),
release("v26.11.0", ALL, { draft: true }),
],
{ currentVersion: "26.10.1", ...win },
),
).toBeNull();
});
it("returns null when nothing is newer", () => {
expect(
updates.resolveRelease([release("v26.10.1", ALL)], {
currentVersion: "26.10.1",
...win,
}),
).toBeNull();
});
it("keeps stable installs off betas", () => {
expect(
updates.resolveRelease([release("v26.11.0-beta.1", ALL)], {
currentVersion: "26.10.1",
...win,
}),
).toBeNull();
});
it("moves a beta install to the next beta, then to stable", () => {
expect(
updates.resolveRelease(
[release("v26.11.0-beta.1", ALL), release("v26.11.0-beta.2", ALL)],
{ currentVersion: "26.11.0-beta.1", ...win },
),
).toMatchObject({ tag: "v26.11.0-beta.2", channel: "beta" });
expect(
updates.resolveRelease(
[release("v26.11.0-beta.2", ALL), release("v26.11.0", ALL)],
{ currentVersion: "26.11.0-beta.2", ...win },
),
).toMatchObject({ tag: "v26.11.0", channel: "latest" });
});
it("waits while the newest release is still uploading", () => {
expect(
updates.resolveRelease(
[release("v26.10.2", ALL), release("v26.10.3", ["termix.exe"])],
{ currentVersion: "26.10.1", ...win },
),
).toBeNull();
});
it("needs this platform's channel file", () => {
const releases = [release("v26.10.2", ["latest.yml", "latest-mac.yml"])];
expect(
updates.resolveRelease(releases, {
currentVersion: "26.10.1",
platform: "linux",
arch: "x64",
}),
).toBeNull();
expect(
updates.resolveRelease(releases, {
currentVersion: "26.10.1",
platform: "darwin",
arch: "arm64",
}),
).not.toBeNull();
});
it("handles junk input", () => {
expect(
updates.resolveRelease(null, { currentVersion: "26.10.1", ...win }),
).toBeNull();
expect(
updates.resolveRelease([release("v26.10.2", ALL)], {
currentVersion: "dev",
...win,
}),
).toBeNull();
});
});
describe("getInstallKind", () => {
const base = {
platform: "win32",
arch: "x64",
isPackaged: true,
mas: false,
env: {},
execPath: path.join("C:", "Program Files", "Termix", "Termix.exe"),
resourcesPath: "/opt/Termix/resources",
exists: () => false,
readFile: () => {
throw new Error("missing");
},
};
it("disables dev builds and leaves the App Store alone", () => {
expect(updates.getInstallKind({ ...base, isPackaged: false })).toBe(
"disabled",
);
expect(
updates.getInstallKind({ ...base, platform: "darwin", mas: true }),
).toBe("store");
});
it("updates NSIS installs and only links MSI, portable and 32-bit", () => {
const uninstaller = path.join(
path.dirname(base.execPath),
"Uninstall Termix.exe",
);
const nsis = (target: string) => target === uninstaller;
expect(updates.getInstallKind({ ...base, exists: nsis })).toBe("auto");
expect(updates.getInstallKind(base)).toBe("manual");
expect(
updates.getInstallKind({ ...base, arch: "ia32", exists: nsis }),
).toBe("manual");
});
it("updates DMG installs unless run from the mounted image", () => {
const mac = { ...base, platform: "darwin", arch: "arm64" };
expect(
updates.getInstallKind({
...mac,
execPath: "/Applications/Termix.app/Contents/MacOS/Termix",
}),
).toBe("auto");
expect(
updates.getInstallKind({
...mac,
execPath: "/Volumes/Termix/Termix.app/Contents/MacOS/Termix",
}),
).toBe("manual");
});
it("tells Linux installs apart", () => {
const linux = { ...base, platform: "linux" };
expect(updates.getInstallKind({ ...linux, env: { FLATPAK_ID: "x" } })).toBe(
"manual",
);
expect(
updates.getInstallKind({
...linux,
exists: (target: string) => target === "/.flatpak-info",
}),
).toBe("manual");
expect(
updates.getInstallKind({ ...linux, env: { APPIMAGE: "/tmp/a" } }),
).toBe("auto");
const dpkgOwned = (target: string) =>
target === "/var/lib/dpkg/info/termix.list";
expect(
updates.getInstallKind({
...linux,
exists: dpkgOwned,
readFile: () => "deb\n",
}),
).toBe("auto");
// The AUR package ships the deb's files without dpkg.
expect(updates.getInstallKind({ ...linux, readFile: () => "deb\n" })).toBe(
"manual",
);
expect(updates.getInstallKind(linux)).toBe("manual");
});
});
describe("createUpdater", () => {
afterEach(() => vi.useRealTimers());
function fakeAutoUpdater() {
const emitter = new EventEmitter() as EventEmitter &
Record<string, unknown>;
emitter.setFeedURL = vi.fn();
emitter.checkForUpdates = vi.fn(async () => {
emitter.emit("update-available", { version: "26.10.2" });
});
emitter.downloadUpdate = vi.fn(async () => {
emitter.emit("update-downloaded", { version: "26.10.2" });
});
emitter.quitAndInstall = vi.fn();
return emitter;
}
function setup(kind = "auto", mode = "prompt") {
const auto = fakeAutoUpdater();
const saved: string[] = [];
const sent: Array<Record<string, unknown>> = [];
const beforeInstall = vi.fn();
const updater = updates.createUpdater({
currentVersion: "26.10.1",
...win,
kind,
getMode: () => mode,
saveMode: (next: string) => saved.push(next),
fetchReleases: async () => [release("v26.10.2", ALL)],
loadAutoUpdater: () => auto,
send: (state: Record<string, unknown>) => sent.push(state),
log: () => {},
beforeInstall,
});
return { auto, updater, saved, sent, beforeInstall };
}
it("asks before downloading in prompt mode", async () => {
const { auto, updater } = setup();
const state = await updater.check();
expect(auto.setFeedURL).toHaveBeenCalledWith({
provider: "generic",
url: "https://github.com/Termix-SSH/Termix/releases/download/v26.10.2",
});
expect(auto.autoDownload).toBe(false);
expect(auto.allowDowngrade).toBe(false);
expect(state).toMatchObject({ status: "available", version: "26.10.2" });
expect((await updater.download()).status).toBe("downloaded");
expect(updater.install()).toBe(true);
});
it("downloads on its own in automatic mode", async () => {
const { auto, updater } = setup("auto", "automatic");
await updater.check();
expect(auto.autoDownload).toBe(true);
expect(updater.getState().status).toBe("downloading");
});
it("marks the quit before installing so the tray lets go", async () => {
vi.useFakeTimers();
const { auto, updater, beforeInstall } = setup();
await updater.check();
await updater.download();
updater.install();
expect(beforeInstall).toHaveBeenCalled();
vi.runAllTimers();
expect(auto.quitAndInstall).toHaveBeenCalled();
});
it("only links the release for installs that can not update", async () => {
const { auto, updater } = setup("manual");
const state = await updater.check();
expect(auto.setFeedURL).not.toHaveBeenCalled();
expect(state).toMatchObject({
status: "available",
releaseUrl: "https://github.com/Termix-SSH/Termix/releases/tag/v26.10.2",
});
expect(updater.install()).toBe(false);
});
it("does nothing for store and dev builds", async () => {
for (const kind of ["store", "disabled"]) {
const { auto, updater } = setup(kind);
expect((await updater.check()).status).toBe("idle");
expect(auto.setFeedURL).not.toHaveBeenCalled();
}
});
it("saves valid modes only and falls back to prompt", () => {
const { updater, saved } = setup("auto", "bogus");
expect(updater.getState().mode).toBe("prompt");
updater.setMode("never");
updater.setMode("sometimes");
expect(saved).toEqual(["never"]);
expect(updater.getState().mode).toBe("never");
});
it("reports a failed lookup instead of throwing", async () => {
const updater = updates.createUpdater({
currentVersion: "26.10.1",
...win,
kind: "auto",
getMode: () => "prompt",
saveMode: () => {},
fetchReleases: async () => {
throw new Error("offline");
},
loadAutoUpdater: () => fakeAutoUpdater(),
send: () => {},
log: () => {},
});
expect(await updater.check()).toMatchObject({
status: "error",
error: "offline",
});
});
});
+11
View File
@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { PRE_29_DATABASE_ERROR } from "../src/backend/database/db/upgrade-guard.js";
const require = createRequire(import.meta.url);
const { classifyBackendFailure } =
@@ -46,6 +47,16 @@ describe("classifyBackendFailure", () => {
});
});
it("identifies a 2.8 database the backend refused to open", () => {
const stderr = `[ERROR] [SYSTEM] Failed to initialize backend services [op:startup_failed]
Error: ${PRE_29_DATABASE_ERROR}
at assertNotPre29Database (upgrade-guard.js:14:11)`;
expect(classifyBackendFailure(stderr)).toEqual({
reason: "old-database",
port: null,
});
});
it("reports a crash for any other non-zero exit", () => {
expect(classifyBackendFailure("TypeError: boom")).toEqual({
reason: "crashed",
+1
View File
@@ -53,6 +53,7 @@ function start() {
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+40
View File
@@ -0,0 +1,40 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
isLaunchableFileName: (fileName: unknown) => boolean;
};
describe("Electron external editor", () => {
it("refuses remote file names the OS would run instead of open", () => {
for (const name of [
"payload.exe",
"run.BAT",
"script.ps1",
"a.exe. ",
"a.cmd...",
"../../evil.vbs",
"start.command",
"launch.desktop",
"install.sh",
]) {
expect(isLaunchableFileName(name)).toBe(true);
}
});
it("opens ordinary text files", () => {
for (const name of [
"nginx.conf",
"README.md",
"Dockerfile",
".bashrc",
"config.json",
"index.ts",
"",
undefined,
]) {
expect(isLaunchableFileName(name)).toBe(false);
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { isExternalLoginUrl, isCallbackPort } =
require("../electron/external-login.cjs") as {
isExternalLoginUrl: (value: unknown) => boolean;
isCallbackPort: (value: unknown) => boolean;
};
describe("Electron external login", () => {
it.each([
"https://idp.example.com/authorize?client_id=termix",
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
])("opens web login URLs: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(true);
});
it.each([
"file:///C:/Windows/System32/calc.exe",
"smb://attacker/share",
"ms-msdt:/id PCWDiagnostic",
"javascript:alert(1)",
"not a url",
undefined,
])("refuses other schemes: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(false);
});
it("accepts the ports the login screen picks", () => {
expect(isCallbackPort(17832)).toBe(true);
expect(isCallbackPort(17931)).toBe(true);
});
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
"refuses callback port %s",
(port) => {
expect(isCallbackPort(port)).toBe(false);
},
);
});
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
}
});
it("never hands the session cookie to the renderer over IPC", () => {
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
expect(main).not.toContain(`"${channel}"`);
expect(preload).not.toContain(`"${channel}"`);
}
});
it("launches only the editor saved from a file dialog", () => {
expect(main).not.toContain("fileData?.editorPath");
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
expect(main).toContain("dialogChosenPaths.has(value)");
});
it("scopes proxy headers and basic auth to the linked origin", () => {
expect(main).toContain('"sync-proxy-config"');
expect(main).toContain("linkedServer.applyLinkHeaders(");
expect(main).toContain("linkedServer.answerLogin(");
});
});
it("rejects setting keys that coerce to the protected editor key", async () => {
const { runInNewContext } = await import("node:vm");
const { vi } = await import("vitest");
const writeFileSync = vi.fn();
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
runInNewContext(
main.slice(
main.indexOf('ipcMain.handle("set-setting"'),
main.indexOf('ipcMain.handle("clear-session-cookies"'),
),
{
ipcMain: {
handle: (_: string, callback: typeof handler) => {
handler = callback;
},
},
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
dialogChosenPaths: new Set(["/chosen/editor"]),
app: { getPath: () => "/settings" },
path: { join: (...parts: string[]) => parts.join("/") },
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
console,
},
);
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
expect(handler!({}, key, "/unapproved/editor")).toEqual({
success: false,
error: "Invalid setting key",
});
}
expect(writeFileSync).not.toHaveBeenCalled();
expect(
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
expect(
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
).toEqual({ success: true });
expect(writeFileSync).toHaveBeenCalledOnce();
});
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Fixes electron-builder's update files (latest.yml and friends) after the
* build, so the desktop updater gets the right download:
*
* --drop <text> remove entries whose file name contains text
* (the ia32 installer, the combined Windows
* installer, the universal mac zip)
* --rename <old=new> follow an artifact the workflow renamed
*
* Usage: node scripts/fix-update-info.cjs [--drop x] [--rename a=b] files...
*/
const fs = require("node:fs");
const yaml = require("js-yaml");
function fixUpdateInfo(info, { drop = [], rename = {} } = {}) {
const rewrite = (name) => rename[name] ?? name;
const seen = new Set();
// electron-builder can list the same file more than once.
const files = (info.files ?? [])
.filter((file) => !drop.some((text) => file.url.includes(text)))
.map((file) => ({ ...file, url: rewrite(file.url) }))
.filter((file) => !seen.has(file.url) && seen.add(file.url));
if (files.length === 0) {
throw new Error("no files left in update info");
}
return { ...info, files, path: files[0].url, sha512: files[0].sha512 };
}
function parseArgs(argv) {
const options = { drop: [], rename: {}, files: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--drop") options.drop.push(argv[++i]);
else if (arg === "--rename") {
const [from, to] = String(argv[++i]).split("=");
if (!from || !to) throw new Error(`bad --rename ${argv[i]}`);
options.rename[from] = to;
} else options.files.push(arg);
}
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
for (const file of options.files) {
if (!fs.existsSync(file)) continue;
const info = yaml.load(fs.readFileSync(file, "utf8"));
fs.writeFileSync(file, yaml.dump(fixUpdateInfo(info, options)));
console.log(`Fixed ${file}`);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
module.exports = { fixUpdateInfo, parseArgs };
+87
View File
@@ -0,0 +1,87 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { fixUpdateInfo, parseArgs } = require("./fix-update-info.cjs");
const info = {
version: "26.10.2",
files: [
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
],
path: "termix_windows_ia32_nsis.exe",
sha512: "a",
releaseDate: "2026-10-11T00:00:00.000Z",
};
describe("fixUpdateInfo", () => {
it("drops entries and points path at what is left", () => {
const fixed = fixUpdateInfo(info, { drop: ["ia32"] });
expect(fixed.files.map((f: { url: string }) => f.url)).toEqual([
"termix_windows_x64_nsis.exe",
]);
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
expect(fixed.sha512).toBe("b");
expect(fixed.version).toBe("26.10.2");
});
it("drops the combined Windows installer and duplicate entries", () => {
const fixed = fixUpdateInfo(
{
...info,
files: [
{ url: "termix_windows_nsis.exe", sha512: "c", size: 3 },
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
],
path: "termix_windows_nsis.exe",
},
{ drop: ["ia32", "termix_windows_nsis.exe"] },
);
expect(fixed.files).toEqual([
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
]);
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
});
it("follows renamed artifacts", () => {
const fixed = fixUpdateInfo(
{
...info,
files: [{ url: "termix_linux_x86_64_appimage.AppImage", sha512: "c" }],
},
{
rename: {
"termix_linux_x86_64_appimage.AppImage":
"termix_linux_x64_appimage.AppImage",
},
},
);
expect(fixed.path).toBe("termix_linux_x64_appimage.AppImage");
});
it("refuses to leave nothing to download", () => {
expect(() => fixUpdateInfo(info, { drop: ["nsis"] })).toThrow();
});
});
describe("parseArgs", () => {
it("reads drops, renames and files", () => {
expect(
parseArgs([
"--drop",
"ia32",
"--rename",
"a=b",
"latest.yml",
"beta.yml",
]),
).toEqual({
drop: ["ia32"],
rename: { a: "b" },
files: ["latest.yml", "beta.yml"],
});
});
});
+73 -99
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,124 +10,98 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
const svgBuffer = readFileSync(join(publicDir, "icon.svg"));
const svgBuffer = readFileSync(join(publicDir, "icon-square.svg"));
const render = (size) =>
sharp(svgBuffer, { density: 72 * Math.max(1, size / 1024) })
.resize(size, size)
.png({ compressionLevel: 9 })
.toBuffer();
const pngSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
const png = Object.fromEntries(
await Promise.all(pngSizes.map(async (s) => [s, await render(s)])),
);
console.log("Generating PNG icons...");
await Promise.all(
pngSizes.map((size) =>
sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(join(iconsDir, `${size}x${size}.png`))
.then(() => console.log(` ✓ icons/${size}x${size}.png`)),
for (const size of pngSizes) {
writeFileSync(join(iconsDir, `${size}x${size}.png`), png[size]);
}
writeFileSync(join(publicDir, "icon.png"), png[1024]);
writeFileSync(join(publicDir, "full-icon.png"), png[1024]);
writeFileSync(join(publicDir, "icon-mac.png"), png[512]);
writeFileSync(
join(publicDir, "favicon.ico"),
buildIco(
[16, 32, 48].map((s) => png[s]),
[16, 32, 48],
),
);
// icon.png (1024x1024) for Linux electron-builder
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "icon.png"));
console.log(" ✓ icon.png");
// icon-mac.png (512x512) for macOS
await sharp(svgBuffer)
.resize(512, 512)
.png()
.toFile(join(publicDir, "icon-mac.png"));
console.log(" ✓ icon-mac.png");
// full-icon.png (1024x1024) used in app UI
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png");
// favicon.ico: embed 16, 32, 48 px layers
console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
icoSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
);
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico");
// icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
console.log("Generating icon.ico...");
const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all(
winSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
writeFileSync(
join(publicDir, "icon.icns"),
buildIcns([
["icp4", png[16]],
["icp5", png[32]],
["icp6", png[64]],
["ic07", png[128]],
["ic08", png[256]],
["ic09", png[512]],
["ic10", png[1024]],
["ic11", png[32]],
["ic12", png[64]],
["ic13", png[256]],
["ic14", png[512]],
]),
);
writeFileSync(join(publicDir, "icon.ico"), buildIco(winBuffers, winSizes));
console.log(" ✓ icon.ico");
// icons/icon.ico and icons/icon.icns placeholders (stubs pointing to source)
// electron-builder generates .icns; copy the 1024 PNG as icons/icon.png for reference
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(iconsDir, "icon.ico").replace("icon.ico", "1024x1024.png"));
// Copy icon.ico and icon.icns into icons/ as well
import { copyFileSync } from "fs";
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
console.log(" ✓ icons/icon.ico");
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
console.log("Icons generated from public/icon-square.svg");
/**
* Builds a minimal ICO file from PNG buffers.
* @param {Buffer[]} pngBuffers
* @param {number[]} sizes
* @returns {Buffer}
*/
function buildIco(pngBuffers, sizes) {
const count = pngBuffers.length;
const headerSize = 6;
const dirEntrySize = 16;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const entries = pngBuffers.map((buf, i) => {
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
const entry = { size: sizes[i], buf, offset };
offset += buf.length;
return entry;
});
const totalSize = offset;
const ico = Buffer.alloc(totalSize);
// ICO header
ico.writeUInt16LE(0, 0); // reserved
ico.writeUInt16LE(1, 2); // type: 1 = ICO
ico.writeUInt16LE(count, 4); // image count
// Directory entries
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
ico.writeUInt8(e.width, base); // width (0 = 256)
ico.writeUInt8(e.height, base + 1); // height (0 = 256)
ico.writeUInt8(0, base + 2); // color count
ico.writeUInt8(0, base + 3); // reserved
ico.writeUInt16LE(1, base + 4); // planes
ico.writeUInt16LE(32, base + 6); // bit count
ico.writeUInt32LE(e.buf.length, base + 8); // size of image data
ico.writeUInt32LE(e.offset, base + 12); // offset of image data
ico.writeUInt8(e.size > 255 ? 0 : e.size, base);
ico.writeUInt8(e.size > 255 ? 0 : e.size, base + 1);
ico.writeUInt16LE(1, base + 4);
ico.writeUInt16LE(32, base + 6);
ico.writeUInt32LE(e.buf.length, base + 8);
ico.writeUInt32LE(e.offset, base + 12);
e.buf.copy(ico, e.offset);
});
// Image data
entries.forEach((e) => e.buf.copy(ico, e.offset));
return ico;
}
function buildIcns(chunks) {
const parts = chunks.map(([type, data]) => {
const head = Buffer.alloc(8);
head.write(type, 0, "ascii");
head.writeUInt32BE(data.length + 8, 4);
return Buffer.concat([head, data]);
});
const body = Buffer.concat(parts);
const head = Buffer.alloc(8);
head.write("icns", 0, "ascii");
head.writeUInt32BE(body.length + 8, 4);
return Buffer.concat([head, body]);
}
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
+33 -6
View File
@@ -20,9 +20,30 @@ const OUT = path.join(
"settings-search-index.ts",
);
function flatKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object"
? flatKeys(value, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
const EN_KEYS = new Set(
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
);
// t("x", { count }) reads x_one or x_other, so index the plural key.
function realKey(key) {
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
? key
: `${key}_other`;
}
function keysIn(text) {
return [
...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])),
...new Set(
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
),
];
}
@@ -110,12 +131,18 @@ function adminSections() {
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
"utf8",
updates: [
...new Set(
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", file),
"utf8",
),
),
),
),
),
],
};
}
+2
View File
@@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+2 -2
View File
@@ -122,10 +122,10 @@ Targets
docker Build the production image and run it with your local plugins
Options
--plugins <dir> Plugin repos to build and watch (default ../Termix-Plugins)
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
+48 -34
View File
@@ -3,43 +3,45 @@ const path = require("node:path");
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
process.exit(0);
}
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
const originalHelperRewrite = [
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n");
// Older installs may still carry the first version of this patch.
const knownHelperRewrites = [
[
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n"),
[
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n"),
];
const patchedHelperRewrite = [
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n");
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
const patchedHelperRewrite =
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
function patchUnixTerminalHelperRewrite() {
if (!fs.existsSync(unixTerminalPath)) {
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
if (!fs.existsSync(file)) {
return false;
}
const source = fs.readFileSync(unixTerminalPath, "utf8");
const source = fs.readFileSync(file, "utf8");
if (source.includes(patchedHelperRewrite)) {
return false;
}
if (!source.includes(originalHelperRewrite)) {
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
if (!known) {
return false;
}
fs.writeFileSync(
unixTerminalPath,
source.replace(originalHelperRewrite, patchedHelperRewrite),
file,
source.replace(known, () => patchedHelperRewrite),
);
return true;
}
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
return fixed;
}
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
// Tests and afterPack require this file; only a direct run patches node_modules.
function main() {
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
return;
}
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
}
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
if (require.main === module) {
main();
}
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
Loaded 100 of 367 files, more files were not shown because too many files have changed in this diff. Show more