Compare commits

..
384 Commits
Author SHA1 Message Date
LukeGus 0324d27f13 fix: folder rows no longer nest action buttons inside a button 2026-10-08 15:28:11 -05:00
LukeGus ab2307bab4 fix: tab buttons like share show as soon as the terminal is ready 2026-10-08 14:04:27 -05:00
LukeGus 5e7f69456b fix: manage tab refetches before giving up on a host or credential it cannot find 2026-10-08 14:04:23 -05:00
LukeGus 2f975f433e fix: keep plugin tailwind variants in core's layer so overrides keep their order 2026-10-08 13:54:37 -05:00
LukeGus 4511cc0a42 fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK 2026-10-08 13:38:56 -05:00
LukeGus 878f1ebaa3 fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 2026-10-08 13:38:52 -05:00
LukeGus d6c4ec01b5 fix: lighter confirm prompt overlay to stop lag when it opens 2026-10-08 00:51:45 -05:00
LukeGus f09865ed04 fix: header separators render as borders and center the status divider 2026-10-08 00:49:11 -05:00
LukeGus cfab6f764c feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 2026-10-08 00:34:50 -05:00
LukeGus 7fcda3bb01 chore: update translation guidance, only en.json needs editing 2026-10-07 22:38:33 -05:00
LukeGus 7161f37984 chore: add LICENSE and use Node 24 from .nvmrc 2026-10-07 22:33:18 -05:00
LukeGus f57c7e8966 chore: remove unused knip config 2026-10-07 22:25:47 -05:00
LukeGus 5774f7cd0e chore: standardize repo setup files 2026-10-07 22:19:20 -05:00
LukeGus 5819348064 feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) 2026-10-07 21:39:31 -05:00
LukeGus c3624cdf62 fix: refuse sessionless tokens and keep the secrets file owner only 2026-10-07 21:20:03 -05:00
LukeGus 740d2adaf3 fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) 2026-10-07 21:18:08 -05:00
LukeGus 8a45d952fd fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) 2026-10-07 21:17:07 -05:00
LukeGus ad3a18224f fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) 2026-10-07 21:14:34 -05:00
LukeGus cd29e1d087 chore: name core and sdk releases Termix vX and Plugin SDK vX 2026-10-07 20:28:54 -05:00
LukeGus 10d0c732be fix: drop the ./ from the plugin sdk bin path so npm stops warning 2026-10-07 20:16:42 -05:00
LukeGus 64eafa0032 feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 2026-10-07 20:09:42 -05:00
LukeGus ea678c5602 fix: import sshpk as a default so the backend starts under ESM 2026-10-07 19:16:15 -05:00
LukeGus fbf7598399 fix: refresh cached plugin translations when only a translation changes (Support#1411) 2026-10-07 16:59:50 -05:00
LukeGus 18a594a92d fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) 2026-10-07 16:56:52 -05:00
LukeGus 679b61da9b fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) 2026-10-07 16:55:38 -05:00
LukeGus f783e6d51d fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) 2026-10-07 16:50:51 -05:00
LukeGus db8305e393 fix: keep client tunnels open when one of their connections fails (Support#1417) 2026-10-07 16:50:07 -05:00
LukeGus 1466c7bcab fix: ask for a push/TOTP menu choice as text instead of a push confirm 2026-10-07 16:40:18 -05:00
ZacharyZcR 42c5682243 fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) 2026-10-07 16:37:58 -05:00
ZacharyZcR bb4b345feb fix(sidebar): reclaim space from hidden folder actions (#1571) 2026-10-07 16:37:54 -05:00
ZacharyZcR 9c31dd41c7 fix(ssh): normalize PKCS8 private keys before connecting (#1570) 2026-10-07 16:37:50 -05:00
ZacharyZcR f4164a74b2 fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) 2026-10-07 16:37:45 -05:00
ZacharyZcR f412a28bb8 fix(sync): resolve shared hosts for connect-only recipients (#1568) 2026-10-07 16:37:41 -05:00
LukeGus f73c4b5ad6 feat: beta channels for core and plugins with one stable/beta release workflow 2026-10-07 16:25:58 -05:00
LukeGus 6a855c0fa8 feat: request a feature link for plugins and use the bug form for unverified plugins 2026-10-07 10:39:52 -05:00
LukeGus 8666533162 fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling 2026-10-06 23:08:21 -05:00
LukeGus 27819c477c fix: retry failed or blocked plugins on restart instead of leaving them disabled 2026-10-06 23:08:10 -05:00
LukeGus b6bb3ffc73 feat: show plugin features from the manifest on the plugin page 2026-10-06 22:38:13 -05:00
LukeGus 60024bdd4e fix: open settings goes to the plugin's page and hides when it has none 2026-10-06 22:26:35 -05:00
LukeGus d1ddcd3ff9 feat: add report a bug link to the your account settings section 2026-10-06 22:23:30 -05:00
LukeGus ac19183f40 feat: per-repo issue forms and prefilled bug reports instead of the support repo 2026-10-06 22:18:01 -05:00
LukeGus 15058119a3 chore: drop optional from ai plugin row in readme 2026-10-06 21:16:48 -05:00
LukeGus 522753fab5 chore: rewrite readme and taglines for plugin-based termix 2026-10-06 21:03:37 -05:00
LukeGus 77c19345b3 chore: general file cleanup 2026-10-06 20:09:18 -05:00
LukeGus df3c762b3d chore: remove dependabot 2026-10-06 20:09:03 -05:00
LukeGus 3b1a85b697 chore: run sdk publish on github hosted runner 2026-10-06 20:06:31 -05:00
LukeGus c828716d6e chore: drop docs job from release workflow 2026-10-06 20:01:12 -05:00
LukeGus 6280e0787c chore: renew mac app store provisioning profile 2026-10-06 19:55:42 -05:00
LukeGus 5a5fb66e9d chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN 2026-10-06 19:44:42 -05:00
LukeGus a608f5561a chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub 2026-10-06 19:41:44 -05:00
LukeGus 7804d4cc8d feat: show only the latest plugin release notes and cap long ones 2026-10-06 19:25:36 -05:00
LukeGus 1d3e77227c chore: release the plugin sdk as 1.0.1 2026-10-06 18:21:24 -05:00
LukeGus ed1ac06cca chore: add temp changelog 2026-10-06 18:18:10 -05:00
LukeGus 9bc2be721b feat: changelog.md release notes, v tags, plugin release notes and videos 2026-10-06 18:13:39 -05:00
LukeGus b9dd3488a5 feat: full screen onboarding with a plugin picker and per step versions 2026-10-06 17:44:25 -05:00
LukeGus 1e74ce4827 chore: update the plugin sdk ui export snapshot 2026-10-06 17:44:22 -05:00
LukeGus fb904554e8 fix: stop the dev runner rebuilding plugins when windows reports file reads as changes 2026-10-06 17:44:17 -05:00
LukeGus 2d71e886ac fix: settle quick connect panel loads in tests so they stop flaking 2026-10-06 16:05:13 -05:00
LukeGus 3ee98664f0 chore: clean up npm scripts, make npm run dev the full runner and drop concurrently 2026-10-06 15:58:27 -05:00
LukeGus ec80a8ef8d feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev 2026-10-06 15:55:00 -05:00
LukeGus 49fc241e06 fix: unify connection failure screens and rebuild stale local plugins 2026-10-06 15:21:44 -05:00
LukeGus a72aa5b57b feat: row detail options for hosts and credentials, open manage from sidebar header 2026-10-06 14:41:57 -05:00
LukeGus 08db1fcdde feat: consent check, developer mode file installs, install counts and issue links for plugins 2026-10-06 14:30:56 -05:00
LukeGus 26a474bb3d fix: open plain settings from the profile button, navigation only from the rail menu 2026-10-06 14:30:49 -05:00
LukeGus dbf6c0117f chore: split dashboard quick actions into quarters and drop host status row lines 2026-10-06 14:30:45 -05:00
LukeGus 46b3adeb57 chore: run all workflows on blacksmith runners 2026-10-06 13:47:24 -05:00
LukeGus 58c3434e58 feat: shared list kit, settings search, rail order and ui consistency fixes 2026-10-06 13:02:05 -05:00
LukeGus 3ed5775ed5 feat: implement plugin management UI 2026-10-06 10:35:56 -05:00
LukeGus 88d7bc5525 feat: pin the official plugins in the docker image 2026-10-05 21:46:16 -05:00
LukeGus 853615af09 fix: stop full database rewrites on every plugin db call 2026-10-05 20:10:09 -05:00
LukeGus 040fbfe315 chore: add a 2.9.1 upgrade check for postgres and mysql 2026-10-05 19:50:28 -05:00
LukeGus 5e54d95e71 fix: harden plugin sockets, public routes, prebuilt binaries and login fallback 2026-10-05 19:50:19 -05:00
LukeGus efb4259438 chore: cleanup 2026-10-05 18:33:59 -05:00
LukeGus 5d67d76cc0 feat: add a publish workflow for the plugin sdk 2026-10-05 18:32:48 -05:00
LukeGus e608505b39 chore: rename the plugin sdk to @termix-ssh/plugin-sdk 2026-10-05 18:23:03 -05:00
LukeGus 0c4f673318 chore: pin the plugin signing key and use TERMIX_PAT in workflows 2026-10-05 17:35:39 -05:00
LukeGus 35c954b79a feat: port the demo redesign with Manage and Settings tabs 2026-10-05 16:23:21 -05:00
LukeGus f8151cb885 chore: remove the 2.9 finish list 2026-10-05 10:32:48 -05:00
LukeGus 61f46d2517 feat: drop the 2.8 columns, moves and aliases kept for a downgrade 2026-10-05 10:21:11 -05:00
dependabot[bot] d3d8269cb7 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>
2026-10-05 01:30:44 -05:00
LukeGus e02b5fe6e4 chore: move plugins to their own repos and drop the plugins folder 2026-10-04 22:40:06 -05:00
LukeGus 29a946d02e chore: rework plugin readmes, json changelogs and standalone repo setup 2026-10-04 21:25:16 -05:00
LukeGus a8f383d2b3 Merge remote-tracking branch 'origin/main' into dev-26.10.0
# Conflicts:
#	package-lock.json
#	package.json
#	plugins/ai/locales/en.json
#	plugins/ai/locales/translated/af_ZA.json
#	plugins/ai/locales/translated/ar_SA.json
#	plugins/ai/locales/translated/bg_BG.json
#	plugins/ai/locales/translated/bn_BD.json
#	plugins/ai/locales/translated/ca_ES.json
#	plugins/ai/locales/translated/cs_CZ.json
#	plugins/ai/locales/translated/da_DK.json
#	plugins/ai/locales/translated/de_DE.json
#	plugins/ai/locales/translated/el_GR.json
#	plugins/ai/locales/translated/es_ES.json
#	plugins/ai/locales/translated/fi_FI.json
#	plugins/ai/locales/translated/fr_FR.json
#	plugins/ai/locales/translated/he_IL.json
#	plugins/ai/locales/translated/hi_IN.json
#	plugins/ai/locales/translated/hu_HU.json
#	plugins/ai/locales/translated/id_ID.json
#	plugins/ai/locales/translated/it_IT.json
#	plugins/ai/locales/translated/ja_JP.json
#	plugins/ai/locales/translated/ko_KR.json
#	plugins/ai/locales/translated/nl_NL.json
#	plugins/ai/locales/translated/no_NO.json
#	plugins/ai/locales/translated/pl_PL.json
#	plugins/ai/locales/translated/pt_BR.json
#	plugins/ai/locales/translated/pt_PT.json
#	plugins/ai/locales/translated/ro_RO.json
#	plugins/ai/locales/translated/ru_RU.json
#	plugins/ai/locales/translated/sr_SP.json
#	plugins/ai/locales/translated/sv_SE.json
#	plugins/ai/locales/translated/th_TH.json
#	plugins/ai/locales/translated/tr_TR.json
#	plugins/ai/locales/translated/uk_UA.json
#	plugins/ai/locales/translated/vi_VN.json
#	plugins/ai/locales/translated/zh_CN.json
#	plugins/ai/locales/translated/zh_TW.json
2026-10-04 17:34:25 -05:00
LukeGus 6b91c3a460 Merge branch 'dev-2.9.1' into dev-26.10.0
# Conflicts:
#	package-lock.json
#	package.json
#	plugins/ai/locales/translated/af_ZA.json
#	plugins/ai/locales/translated/ar_SA.json
#	plugins/ai/locales/translated/bg_BG.json
#	plugins/ai/locales/translated/bn_BD.json
#	plugins/ai/locales/translated/ca_ES.json
#	plugins/ai/locales/translated/cs_CZ.json
#	plugins/ai/locales/translated/da_DK.json
#	plugins/ai/locales/translated/de_DE.json
#	plugins/ai/locales/translated/el_GR.json
#	plugins/ai/locales/translated/es_ES.json
#	plugins/ai/locales/translated/fi_FI.json
#	plugins/ai/locales/translated/fr_FR.json
#	plugins/ai/locales/translated/he_IL.json
#	plugins/ai/locales/translated/hi_IN.json
#	plugins/ai/locales/translated/hu_HU.json
#	plugins/ai/locales/translated/id_ID.json
#	plugins/ai/locales/translated/it_IT.json
#	plugins/ai/locales/translated/ja_JP.json
#	plugins/ai/locales/translated/ko_KR.json
#	plugins/ai/locales/translated/nl_NL.json
#	plugins/ai/locales/translated/no_NO.json
#	plugins/ai/locales/translated/pl_PL.json
#	plugins/ai/locales/translated/pt_BR.json
#	plugins/ai/locales/translated/pt_PT.json
#	plugins/ai/locales/translated/ro_RO.json
#	plugins/ai/locales/translated/ru_RU.json
#	plugins/ai/locales/translated/sr_SP.json
#	plugins/ai/locales/translated/sv_SE.json
#	plugins/ai/locales/translated/th_TH.json
#	plugins/ai/locales/translated/tr_TR.json
#	plugins/ai/locales/translated/uk_UA.json
#	plugins/ai/locales/translated/vi_VN.json
#	plugins/ai/locales/translated/zh_TW.json
2026-10-04 17:34:09 -05:00
LukeGus 8520076192 fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi 2026-10-04 15:40:57 -05:00
LukeGus e083f09658 chore: sync Crowdin translations for 2.9.1 2026-10-04 20:11:19 +00:00
LukeGus dd66aa57e4 Merge remote-tracking branch 'origin/main' into dev-2.9.1 2026-10-04 20:10:22 +00:00
LukeGus 0a436ab3d8 fix: simplify host status to online/offline and keep it live without a refresh 2026-10-04 14:18:50 -05:00
LukeGus 2646b649d7 fix: remote desktop logins missing from shared hosts on desktop 2026-10-04 13:55:55 -05:00
LukeGus 08e1eea2f3 fix: tunnels and host settings missing from shared hosts on desktop 2026-10-04 13:54:09 -05:00
LukeGus 5a373c3963 chore: add sso/ldap dialog and audit log fixes to release notes 2026-10-04 13:53:38 -05:00
ZacharyZcR abbfca2e94 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.
2026-10-04 18:40:13 +08:00
LukeGus 4c9e12f3b7 chore: increment ver 2026-10-03 16:56:19 -05:00
LukeGus 58577eafe9 fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles 2026-10-03 16:48:03 -05:00
LukeGus e489d804f9 Merge branch 'dev-2.9.1' into dev-26.10.0 2026-10-03 16:36:59 -05:00
LukeGus 0f4cdcbdfa chore: add host key and status label fixes to release notes 2026-10-03 16:36:54 -05:00
LukeGus 99d9db04d4 fix: clearer host login failed status label and fix its translations (#1396) 2026-10-03 16:36:37 -05:00
LukeGus b3e220acf1 fix: host key silently accepted when the host is missing from the database (#1397) 2026-10-03 16:35:09 -05:00
LukeGus 44be18988b fix: restore space to add host tags and redesign predefined tags editor 2026-10-03 16:22:13 -05:00
LukeGus 7305a44b19 test: run the posix-only ai agent tests on linux only 2026-10-03 16:12:38 -05:00
ZacharyZcR bf817d8bd1 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
2026-10-03 16:01:47 -05:00
LukeGus 77bcbdb3d6 chore: merge dev-2.9.1 into dev-26.10.0 2026-10-03 16:01:42 -05:00
LukeGus 69a03f6205 chore: update release notes for 2.9.1 2026-10-03 16:00:16 -05:00
LukeGus f52f07f975 chore: drop em dash from host row comment 2026-10-03 15:59:50 -05:00
LukeGus fa21a7a458 test: compare download stream buffers directly so it stops timing out 2026-10-03 15:59:46 -05:00
LukeGus 7156ac1af3 fix: add openapi docs and error handling to host tag routes 2026-10-03 15:45:41 -05:00
LukeGus a1b37c6829 fix: slow file deletes from the trash lookups on every delete (#1390) 2026-10-03 15:45:01 -05:00
LukeGus b03000e396 fix: op:// secret references rejected as ssh keys on credentials (#1394) 2026-10-03 15:41:44 -05:00
LukeGus 86840f7642 fix: user data export freezing the server (#1393) 2026-10-03 15:38:31 -05:00
LukeGus 79bd10a1b0 fix: high cpu from status probes and full database saves on every sample (#1300) 2026-10-03 15:37:11 -05:00
LukeGus 11a22d30eb fix: host imports running a defaults pass and metrics restart per host (#1384) 2026-10-03 15:37:07 -05:00
LukeGus 5e31642379 fix: fleets, proxmox and automations not getting the host sudo password 2026-10-03 15:31:05 -05:00
LukeGus 6f442c5863 fix: plugins losing the saved ssh login when copying a host (#1391) 2026-10-03 15:31:01 -05:00
LukeGus d433718c35 fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) 2026-10-03 15:27:15 -05:00
ZacharyZcR 0214a81e5d 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
2026-10-03 21:37:00 +08:00
ZacharyZcR 13f5736d8f test(database): advance fake timers past the save yield (#1555) 2026-10-03 21:28:28 +08:00
ZacharyZcR b00551c394 feat(hosts): add instance-wide predefined tag suggestions (#1548)
* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes
2026-10-03 21:04:50 +08:00
ZacharyZcR 94a9f815c9 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.
2026-10-03 21:04:33 +08:00
ZacharyZcR 59083aac00 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".
2026-10-03 21:04:21 +08:00
ZacharyZcR 57151c0e84 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.
2026-10-03 21:04:04 +08:00
ZacharyZcR ab7a68aa84 fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) 2026-10-03 21:03:47 +08:00
ZacharyZcR 7b2198e18c fix(auth): reject unresolved legacy identity provisioning (#1550) 2026-10-03 21:03:31 +08:00
ZacharyZcR c9a1348836 fix(auth): allow retrying unavailable second-factor interfaces (#1549) 2026-10-03 21:03:13 +08:00
ZacharyZcR c66702deb1 feat(hosts): keep compact row actions inline (#1547) 2026-10-03 21:02:54 +08:00
ZacharyZcR 2c4de5f705 fix(file-manager): render transfer toasts without plugin hooks (#1546) 2026-10-03 21:02:38 +08:00
ZacharyZcR 4785bf5dfa fix(docker): retain stored SSH credential association (#1543) 2026-10-03 21:02:21 +08:00
ZacharyZcR 117e125d55 fix(snippets): repair missing note column on SQLite upgrades (#1541) 2026-10-03 21:02:04 +08:00
ZacharyZcR d3568e484d docs(api): describe cookie and API key authentication (#1540) 2026-10-03 21:01:47 +08:00
ZacharyZcR c78bc37eb2 fix(database): batch session activity persistence (#1539) 2026-10-03 21:01:30 +08:00
ZacharyZcR dc5d57c012 fix(status): skip TCP probes while host sessions are active (#1538) 2026-10-03 21:01:12 +08:00
ZacharyZcR bd0858b5a2 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
2026-10-03 21:00:55 +08:00
ZacharyZcR 021670ca1d test(remote-desktop): preserve sessions beyond one hour (#1536) 2026-10-03 21:00:38 +08:00
ZacharyZcR dc6c522782 fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) 2026-10-03 21:00:20 +08:00
ZacharyZcR 93db4799c0 fix(sso): send the PKCE verifier for GitHub login (#1534) 2026-10-03 21:00:03 +08:00
LukeGus d4b68ea5a2 chore: switch to calver, bump version to 26.10.0 2026-10-01 21:47:45 -05:00
LukeGus 29719dbb87 chore: skip crowdin pre-translate in release for now 2026-10-01 16:05:50 -05:00
LukeGus 36325b38ec Merge remote-tracking branch 'origin/main' into dev-2.9.0 2026-10-01 20:23:47 +00:00
LukeGus b02f16ea0a chore: lint, format, and bump version to 2.9.0 2026-10-01 20:23:09 +00:00
LukeGus 1c9f561f7f fix: build plugin sdk before linting in release workflow 2026-10-01 15:12:42 -05:00
LukeGus 7dbdf36557 fix: remote desktop tabs reconnecting on every tab switch 2026-10-01 14:38:49 -05:00
LukeGus e4ffebb69c chore: update release notes 2026-10-01 14:38:41 -05:00
LukeGus 30da0850da fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag 2026-10-01 13:14:11 -05:00
LukeGus 3b58415f8f fix: maintenance index name, shared host read only view and badge polling 2026-10-01 13:14:07 -05:00
LukeGus 5ff5ae19d9 fix: network graph lag when adding hosts and on every render 2026-10-01 13:14:03 -05:00
LukeGus 744510520d fix: session log player scrolling off screen horizontally 2026-10-01 13:14:00 -05:00
LukeGus deb86d11b6 fix: fleet detail buttons overflowing the sidebar 2026-10-01 13:13:55 -05:00
LukeGus f9ad5a0189 fix: onboarding rerunning for existing users after upgrading 2026-10-01 13:13:52 -05:00
LukeGus 95a5e75495 fix: file manager and sftp text showing raw i18n keys 2026-10-01 13:13:45 -05:00
LukeGus 9015d5dccb fix: redesign session log player controls, fix tooltip overlap and drop dot separators 2026-10-01 00:09:32 -05:00
LukeGus b3c8f37c0d fix: remove duplicate top border on split screen menu 2026-09-30 23:58:45 -05:00
LukeGus f3ed947a7c fix: recent activity card no longer cuts off host names early 2026-09-30 23:56:20 -05:00
LukeGus 6217b91ae2 feat: move telemetry out of core into a usage statistics plugin 2026-09-30 23:54:38 -05:00
LukeGus f8edb5f299 fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release 2026-09-30 23:27:34 -05:00
LukeGus 1c4b057fd8 fix: add description to the web endpoint enable switch 2026-09-30 23:14:26 -05:00
LukeGus 7f0a825583 fix: remote desktop auth method labels and notes for vnc and telnet 2026-09-30 23:14:21 -05:00
LukeGus d2a1be2eb0 fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default 2026-09-30 23:14:17 -05:00
LukeGus f101d29d8a fix: drop the top border on the split layout menu 2026-09-30 22:48:19 -05:00
LukeGus 49acec4770 fix: host status shows online when the port answers and only warns on a failed login 2026-09-30 18:18:38 -05:00
LukeGus c28cb1bfc6 fix: hide the built-in source pill in the host editor 2026-09-30 17:50:42 -05:00
LukeGus f97f35de1f fix: count plugin ssh logins toward host status and stop showing reachable as a warning 2026-09-30 17:49:09 -05:00
LukeGus 69b1b9805b fix: proxmox stats tab reads its switch from plugin host settings 2026-09-30 17:48:37 -05:00
LukeGus ba05b6a610 chore: fix dependabot alerts and apply safe npm upgrades 2026-09-30 17:18:26 -05:00
LukeGus 90c6540f85 fix: docker container logs use the base background color 2026-09-30 17:06:45 -05:00
LukeGus 14cf0946b3 fix: quick connect fills the host address from plugin auth editors like tailscale 2026-09-30 16:50:58 -05:00
LukeGus e1f2cc975d feat: restore the alert feed homepage widget 2026-09-30 16:21:22 -05:00
LukeGus 0d94ad5e5f revert: drop the alert feed widget, the notification center replaces it 2026-09-30 16:19:40 -05:00
LukeGus 84c94bf9a6 fix: declare terminal.listSessions use for snippet targets 2026-09-30 14:56:51 -05:00
LukeGus de9b4a064c chore: openapi docs for undocumented routes and fix two broken doc blocks 2026-09-30 14:55:13 -05:00
LukeGus 5ecf8156e6 chore: openapi docs for host metrics manager routes 2026-09-30 14:49:56 -05:00
LukeGus d8fb2e75af fix: show the remote desktop note under connection origin again 2026-09-30 14:45:18 -05:00
LukeGus 9ce63d65d3 fix: toolbar files button opens at the shell directory again 2026-09-30 14:42:29 -05:00
LukeGus 20f19ff13b fix: translate leftover hardcoded ui text 2026-09-30 14:39:28 -05:00
LukeGus 1b342322dc fix: translate hardcoded toasts and drop em dashes from ui text 2026-09-30 14:25:51 -05:00
LukeGus 3c66931245 feat: list the fixed app shortcuts in keyboard shortcuts 2026-09-30 14:19:17 -05:00
LukeGus 25cbd40283 feat: recent servers list when linking the desktop app 2026-09-30 14:17:31 -05:00
LukeGus cb12541c16 feat: share button on connection tabs again 2026-09-30 14:16:11 -05:00
LukeGus f2c9358ecf feat: pick target terminals for snippets again 2026-09-30 14:12:52 -05:00
LukeGus 80f089268a feat: bring back the alert feed homepage widget 2026-09-30 14:10:07 -05:00
LukeGus 5f2b10fed8 feat: tighter compact host rows with inline tags (#1325) 2026-09-30 14:03:56 -05:00
LukeGus 911fc3bc69 feat: optional auto reconnect for dropped ssh sessions (#1321) 2026-09-30 14:02:42 -05:00
LukeGus 41d0c0ab85 fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312) 2026-09-30 13:58:26 -05:00
LukeGus 09259de01c fix: cover the gnome close path in the desktop quit test (#1332) 2026-09-30 12:16:00 -05:00
LukeGus 71bbec7084 fix: load the linux tray icon reliably and log tray failures (#1332) 2026-09-30 12:15:35 -05:00
LukeGus da588f8fa1 fix: show disk usage for the mount being browsed (#1347) 2026-09-30 12:14:26 -05:00
LukeGus e3eada899c fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335) 2026-09-30 12:12:41 -05:00
LukeGus f896e121e2 fix: show meeting guests to members and hide the roster from guests (#1353) 2026-09-30 12:09:05 -05:00
LukeGus ab43563247 fix: build desktop session share links on the linked server (#1350) 2026-09-30 12:04:25 -05:00
LukeGus d30875483d fix: readable terminal selection with the light theme (#1319) 2026-09-30 11:58:35 -05:00
LukeGus e88df7827b fix: prefer ipv4 when resolving ssh host names (#1365) 2026-09-30 11:56:14 -05:00
LukeGus 4076eb66ab fix: ship the plugin sdk and serialport in desktop builds 2026-09-30 11:21:16 -05:00
ZacharyZcR b45d0f9ed2 feat(installer): make the Windows desktop shortcut optional (#1517) 2026-09-30 11:20:44 -05:00
LukeGus f4c9090b88 fix: type errors and lint failures from merged plugin prs 2026-09-30 10:04:17 -05:00
ZacharyZcR 9b690b9935 fix(tunnels): support TOTP for client tunnels and jump hosts (#1508) 2026-09-30 09:55:34 -05:00
ZacharyZcR ec7c2c2835 fix(session-sharing): use the linked server for desktop meetings and links (#1507) 2026-09-30 09:54:23 -05:00
ZacharyZcR 9801cc7fc7 feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513) 2026-09-30 09:53:17 -05:00
ZacharyZcR 99cffc9ba6 feat(automations): add per-host maintenance mode and recurring schedules (#1521) 2026-09-30 09:51:51 -05:00
ZacharyZcR fd91d4925a feat(sidebar): add keyboard type-ahead search for hosts (#1516) 2026-09-30 09:51:10 -05:00
ZacharyZcR 5b0423c8e0 fix(database): preserve RDP domain during startup backfill (#1497) 2026-09-30 09:50:32 -05:00
ZacharyZcR 1f4b1ff6a4 fix(hosts): preserve recording and sharing settings in the dev editor (#1509) 2026-09-30 09:49:13 -05:00
ZacharyZcR e161ad22e0 feat: reconnect all disconnected terminal tabs (#1523) 2026-09-30 09:48:15 -05:00
ZacharyZcR 5860921cbe fix(electron): allow explicit quit with active connections (#1522) 2026-09-30 09:47:26 -05:00
ZacharyZcR 4c2f54dac0 feat(tmux-monitor): add a unified multi-host view (#1520) 2026-09-30 09:47:21 -05:00
ZacharyZcR cbbb80bc82 fix(shortcuts): prevent accidental double-Shift palette activation (#1519) 2026-09-30 09:47:16 -05:00
ZacharyZcR 7d06686f62 feat(homepage): synchronize canvas layouts across devices (#1515) 2026-09-30 09:47:08 -05:00
ZacharyZcR fdb2f74259 feat(file-manager): default to the remote login directory (#1512) 2026-09-30 09:47:02 -05:00
ZacharyZcR e69029fbf7 feat(file-manager): allow disabling move-to-trash confirmation (#1511) 2026-09-30 09:46:56 -05:00
ZacharyZcR fef33142b3 feat(ssh-terminal): add a duplicate tab action (#1510) 2026-09-30 09:46:50 -05:00
ZacharyZcR 55d7c8837b fix(ai): sync provider configuration and keys across linked devices (#1506) 2026-09-30 09:46:45 -05:00
ZacharyZcR 4ed9b69aab fix(session-sharing): show participant presence in member terminals (#1504) 2026-09-30 09:46:40 -05:00
ZacharyZcR 0f6e48a95c fix(session-sharing): synchronize shared SSH terminal dimensions (#1502) 2026-09-30 09:46:35 -05:00
ZacharyZcR c265758cdd fix(ssh-terminal): close owner sockets when presentations unmount (#1501) 2026-09-30 09:46:29 -05:00
ZacharyZcR 24def2b613 fix(hosts): preserve plugin settings when reopening the editor (#1499) 2026-09-30 09:46:23 -05:00
ZacharyZcR 718717554d fix(sidebar): keep compact host addresses visible (#1524) 2026-09-30 09:46:00 -05:00
ZacharyZcR 64b9b18fe6 fix(file-manager): restore text selection and copying in Markdown previews (#1518) 2026-09-30 09:45:54 -05:00
ZacharyZcR a1f72b9e53 feat(remote-desktop): expose display zoom controls for RDP (#1514) 2026-09-30 09:45:49 -05:00
ZacharyZcR 12a373e598 fix(ssh-terminal): preserve input order during local echo reconciliation (#1505) 2026-09-30 09:45:44 -05:00
ZacharyZcR 126a8c433b fix(file-manager): refresh disk usage with directory listing (#1503) 2026-09-30 09:45:39 -05:00
ZacharyZcR f6ea218100 fix(session-sharing): replace local presentation after takeover (#1500) 2026-09-30 09:45:34 -05:00
ZacharyZcR fb28cd1f21 fix(remote-desktop): release held keys on focus loss (#1498) 2026-09-30 09:45:29 -05:00
LukeGus a4a4d4f27b feat: redesign user host defaults system 2026-09-30 06:19:56 -05:00
LukeGus 3028ab1176 fix: load and save plugin host settings in the editor and tighten settings row spacing 2026-09-29 22:40:01 -05:00
LukeGus 1a6f9ffad6 fix: keep plugin css below core utilities and version plugin assets by their css 2026-09-29 19:06:57 -05:00
LukeGus 80944301dd feat: harden plugin api for separate repos, signed updates and generic external login 2026-09-29 18:11:59 -05:00
LukeGus fb9c168b6b feat: plugins only see their own host settings in the browser 2026-09-29 13:38:11 -05:00
LukeGus 50d43e29fb feat: replace core homepage widget registry with generic extension points 2026-09-29 13:23:51 -05:00
LukeGus b8bbcd2196 feat: type the plugin host record, move plugin strings out of core and add lint checks 2026-09-29 13:08:39 -05:00
LukeGus bba06f7ff6 feat: store rdp, vnc and telnet logins in a generic per-protocol table 2026-09-29 12:33:09 -05:00
LukeGus e0c30a4e6f feat: move snippets, keybinding actions and macros out of core into plugins 2026-09-29 11:39:14 -05:00
LukeGus d9bba10612 feat: move terminal settings, look and ssh tools into ssh-terminal plugin 2026-09-29 02:38:37 -05:00
LukeGus ffa872ebd3 fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport 2026-09-28 21:59:37 -05:00
LukeGus 44062e30d1 feat: improve quick connect system using plugin api 2026-09-28 21:23:06 -05:00
LukeGus b4457b6121 fix(ai): stop streams getting cut off and keep tool steps across messages 2026-09-28 21:08:48 -05:00
LukeGus c5b2b880b6 feat(ssh-terminal): add host setting to turn off password prompt auto-fill 2026-09-28 20:51:16 -05:00
LukeGus f0d720cf05 feat(ssh-terminal): add toolbar position, start state, display and fade host settings 2026-09-28 20:45:23 -05:00
LukeGus 1fd89acd90 feat(snippets): bring back sharing, target hosts and drag reorder 2026-09-28 20:34:51 -05:00
LukeGus 796db8f0a8 fix(session-recording): stop session logs panel from refetching in a loop 2026-09-28 20:14:54 -05:00
LukeGus 75c3542ba0 feat(snippets): redesign panel to match the host list 2026-09-28 20:13:08 -05:00
LukeGus 278fc7e32a feat(sidebar): add host click behavior setting, default to always opening a new tab 2026-09-28 19:56:52 -05:00
LukeGus 81e7aa6274 feat(split-screen): redesign split screen with free-form panes and pane picker 2026-09-28 19:49:04 -05:00
ZacharyZcR 5f3372f9f6 fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation
2026-09-27 17:36:11 -05:00
ZacharyZcR 107cf55d8c fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation
2026-09-27 17:36:01 -05:00
ZacharyZcR 4e652720af fix(electron): signal backend readiness over IPC (#1488) 2026-09-27 17:35:40 -05:00
ZacharyZcR 407d358f6b fix(file-manager): show download progress in file viewers (#1487) 2026-09-27 17:35:33 -05:00
LukeGus 7b1e0294aa feat: add descriptions to plugin host enable toggles 2026-09-27 14:14:51 -05:00
LukeGus c7fd900732 feat: merge host editor terminal tabs into one owned by ssh-terminal 2026-09-27 14:03:02 -05:00
LukeGus fff373d5bd chore: remove references to plugin sdk ARCHITECTURE.md 2026-09-27 14:02:40 -05:00
LukeGus 4ce551822b chore: untrack plugin sdk ARCHITECTURE.md 2026-09-27 14:00:49 -05:00
LukeGus bbeed088ad fix: compute beta changes across dev branches and move the beta tag 2026-09-27 01:12:39 -05:00
ZacharyZcR 378be3405a fix(ldap): preserve the directory-provided bind DN (#1486) 2026-09-27 00:51:18 -05:00
LukeGus 4897024324 fix: make link-store secret check immune to random base64 matches 2026-09-27 00:50:14 -05:00
LukeGus 25d11b1edb Merge remote-tracking branch 'origin/dev-2.9.0' into dev-2.9.0 2026-09-27 00:45:11 -05:00
LukeGus 50a263a8dc feat: add plugin sdk build to beta release 2026-09-27 00:45:04 -05:00
ZacharyZcR 0c46950d4c fix(file-manager): pipeline SFTP download reads (#1485) 2026-09-27 00:39:03 -05:00
ZacharyZcR 98cf62547f fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:38:55 -05:00
LukeGus 4423c4c8ef fix: keep plugins loaded after signing in from the login screen 2026-09-27 00:38:06 -05:00
LukeGus 8043d6dbdd chore: simplify plugin readmes, changelogs and manifest descriptions 2026-09-27 00:24:48 -05:00
ZacharyZcR 7423e33184 fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:12:08 -05:00
ZacharyZcR 2824b49ba9 fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:11:41 -05:00
ZacharyZcR 3b9a1bacf0 fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures
2026-09-27 00:11:09 -05:00
ZacharyZcR c98003f90e fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:10:51 -05:00
ZacharyZcR 44f751a5e5 fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:41 -05:00
ZacharyZcR 322f811f2c fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:28 -05:00
ZacharyZcR 2a2ba2f790 fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:19 -05:00
LukeGus 26d52c7631 fix: keep rounded-full circular in production builds 2026-09-26 18:49:48 -05:00
ZacharyZcR 2770f14a25 fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F
2026-09-26 18:19:35 -05:00
ZacharyZcR 5e02e30d0f fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown
2026-09-26 18:19:25 -05:00
ZacharyZcR 8192886a69 docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields
2026-09-26 18:19:15 -05:00
ZacharyZcR 4a1880acb8 fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels
2026-09-26 18:19:05 -05:00
ZacharyZcR 488e940c1a fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent
2026-09-26 18:18:40 -05:00
ZacharyZcR f05f626507 fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:30 -05:00
ZacharyZcR ec982aadb5 fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:21 -05:00
ZacharyZcR 710f545e34 fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:12 -05:00
LukeGus c38c2e84b8 fix: link every plugin workspace before npm ci in docker deps stage 2026-09-26 13:05:16 -05:00
LukeGus 2ca7a36dbc fix: link plugin-sdk workspace before npm ci in docker deps stage 2026-09-26 12:56:31 -05:00
LukeGus e14c0b32e0 fix: set blacksmith builder cache-key to stop broken gha cache fallback 2026-09-26 12:53:21 -05:00
LukeGus 6cd9a2846d feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling 2026-09-26 00:41:25 -05:00
LukeGus 7c02daccf4 feat: unify alerts into one plugin, channels, rules and inbox 2026-09-25 16:26:37 -05:00
LukeGus fbe736bc20 feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning 2026-09-25 13:06:33 -05:00
LukeGus c21bca8cf0 feat: show credential sharing inline in the sidebar like host sharing 2026-09-25 12:03:41 -05:00
LukeGus 4a320a2258 feat: improve UI loading time and jitter 2026-09-25 11:33:24 -05:00
LukeGus 0f8956dcf3 fix: give bundled plugin backends a real require for CJS deps 2026-09-25 10:13:52 -05:00
LukeGus f779d6a08e test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught 2026-09-25 09:58:45 -05:00
LukeGus 2ee9b3592c feat: add plugin packing, signing, pinned trust and tmxplug bundling 2026-09-25 09:21:28 -05:00
LukeGus c6a44b7ff3 fix: harden the plugin runtime after the 2.9.0 security review 2026-09-25 08:48:54 -05:00
LukeGus 2961297989 chore: lock the plugin boundary and remove 2.9.0 leftovers 2026-09-25 07:24:46 -05:00
LukeGus 6489b62ece fix: finish 2.9.0 plugin moves and get every check green 2026-09-24 23:35:18 -05:00
LukeGus 8c1e02af89 feat: move acme certificate automation into a plugin (C7) 2026-09-24 21:19:27 -05:00
LukeGus 4dbce08379 feat: move termix identity into a plugin (C6) 2026-09-24 20:50:54 -05:00
LukeGus 6e56c90aac feat: move vault ssh signing into a plugin (C5) 2026-09-24 20:24:09 -05:00
LukeGus b027717c46 feat: move step-ca into a plugin (C4) 2026-09-24 19:55:28 -05:00
LukeGus 98d367d7e2 feat: move opkssh and warpgate into plugins (C3) 2026-09-24 19:28:19 -05:00
LukeGus 925da4a661 feat: move sso and ldap logins into plugins (C2) 2026-09-24 18:23:20 -05:00
LukeGus 5c18435105 feat: move totp and passkeys into plugins (C1) 2026-09-24 16:57:24 -05:00
LukeGus f4f26a9b8b feat: convert secret-sources into a plugin (B20) 2026-09-24 16:20:20 -05:00
LukeGus 2ac7779eda feat: convert wake-on-lan into a plugin (B20) 2026-09-24 15:49:55 -05:00
LukeGus d4ec444558 feat: finish the homepage plugin, move service links and canvas out of core (B19) 2026-09-24 15:07:25 -05:00
LukeGus 21937a6b02 feat: finish the ai plugin (B18) 2026-09-24 13:43:26 -05:00
LukeGus 6b4d1432ab feat: finish the automations plugin (B17) 2026-09-24 13:05:44 -05:00
LukeGus 993a91c899 feat: finish the docker plugin (B15) 2026-09-24 11:58:20 -05:00
LukeGus 0a3f05d99d feat: finish the host-metrics plugin, move host status into core (B16) 2026-09-24 11:21:23 -05:00
LukeGus 14eb558b85 feat: finish the remote-desktop plugin (B14) 2026-09-24 01:02:45 -05:00
LukeGus 0af2c94983 feat: turn session recording into a plugin (B13) 2026-09-24 00:09:48 -05:00
LukeGus a276f6898c feat: move session sharing and collab rooms into a plugin (B12) 2026-09-23 23:42:46 -05:00
LukeGus 791d8656a1 feat: convert serial console into the serial plugin 2026-09-23 23:06:37 -05:00
LukeGus 708eedb861 feat: convert tmux monitoring into a plugin (B10) 2026-09-23 22:42:56 -05:00
LukeGus 79d77678c9 feat: move the terminal fully into the ssh-terminal plugin (B9) 2026-09-23 21:41:22 -05:00
LukeGus 535126fd1b feat: finish the web-endpoint plugin, add ctx.desktop (B8) 2026-09-23 18:36:41 -05:00
LukeGus 1682861853 feat: convert ssh tunnels into a plugin 2026-09-23 17:27:28 -05:00
LukeGus c85c1fdb86 feat: convert file manager into a plugin 2026-09-23 16:27:29 -05:00
LukeGus b76da9ab52 Merge remote-tracking branch 'origin/dev-2.9.0' into dev-2.9.0 2026-09-23 13:10:24 -05:00
LukeGus c381955ed0 feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) 2026-09-23 13:10:18 -05:00
LukeGus bf147096eb feat: drop proxmox host columns, use plugin settings instead 2026-09-23 13:06:43 -05:00
LukeGus 37c015a760 feat: move proxmox node stats and collectors into the proxmox plugin 2026-09-23 13:06:18 -05:00
LukeGus 218c5088e7 feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox 2026-09-23 13:06:06 -05:00
LukeGus f9283ec83f chore: regenerate drizzle migration journals for B5 2026-09-23 13:05:55 -05:00
LukeGus 115bc515e1 chore: update proxmox and host-metrics locale files for B5 2026-09-23 13:05:19 -05:00
LukeGus 3b2c849514 feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) 2026-09-23 11:24:16 -05:00
LukeGus d2ff5fc78f feat: finish the tailscale plugin, including SSH auth (B3) 2026-09-23 09:41:20 -05:00
LukeGus c77301577f feat: wire ai, automations, fleets and ssh-terminal to snippets (B2) 2026-09-23 08:59:24 -05:00
LukeGus 3656caf168 feat: add the snippets plugin (B2) 2026-09-23 08:59:16 -05:00
LukeGus 1eac0f54b0 feat: point core frontend snippets pickers at the plugin (B2) 2026-09-23 08:59:06 -05:00
LukeGus e07221eaf3 feat: move snippets backend and tables into a plugin (B2) 2026-09-23 08:58:53 -05:00
LukeGus fdedca39fd feat: convert network-topology to a full plugin (B1) 2026-09-23 07:36:39 -05:00
LukeGus 95abe24798 fix: gate second factors after SSO/LDAP behind an admin setting 2026-09-23 07:17:03 -05:00
LukeGus f217d8db89 feat: rebuild workspaces as the reference plugin 2026-09-23 07:04:03 -05:00
LukeGus d774df8b41 feat: add auth extension points and one ssh connect pipeline 2026-09-23 00:44:33 -05:00
LukeGus d3c209af13 fix: mock the ai status fetch in its activate test 2026-09-22 20:20:47 -05:00
LukeGus e88d5a52e3 feat: load plugin frontends at runtime and remove plugin ids from the shell 2026-09-22 20:15:52 -05:00
LukeGus 49f0286a14 feat: give plugins their own settings and one settings screen 2026-09-22 18:31:19 -05:00
LukeGus cc15559ea1 feat: namespace plugin permissions and keep them valid when a plugin is off 2026-09-22 17:30:00 -05:00
LukeGus 8431570ff7 feat: serve plugin http and websockets from one mount instead of ports 2026-09-22 16:39:41 -05:00
LukeGus c496a093ca feat: give plugins their own tables, migrations and sync entities 2026-09-22 15:49:54 -05:00
LukeGus 5796186a1c feat: make each plugin an npm workspace built by a termix-plugin CLI 2026-09-22 15:09:50 -05:00
LukeGus 4758016f41 feat: collapse plugin runtime to one tier behind a plugin SDK 2026-09-22 14:11:50 -05:00
LukeGus 6e058f6e9e feat: move web endpoint into a first-party plugin 2026-09-22 12:30:35 -05:00
LukeGus 6f0dff58a3 feat: move workspaces into a first-party plugin 2026-09-22 10:45:15 -05:00
LukeGus 8742c5d381 feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
2026-09-22 10:45:15 -05:00
LukeGus 5fbd5fb7e2 feat: move network topology into a first-party plugin 2026-09-22 10:45:15 -05:00
LukeGus e372b0eda7 feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
2026-09-22 10:45:12 -05:00
LukeGus e0f14f2ecf feat: move automations into a first-party plugin 2026-09-22 08:14:03 -05:00
LukeGus a6f6a0439c feat: move fleets into a first-party plugin 2026-09-21 20:44:00 -05:00
LukeGus 76b2e5739a feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
2026-09-21 20:26:09 -05:00
LukeGus c6bcfdb80d feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
2026-09-21 20:25:52 -05:00
LukeGus d440ce9073 feat: move proxmox discovery/import/sync into a first-party plugin 2026-09-21 19:20:17 -05:00
LukeGus 9db66d5780 feat: add cross-plugin secret references gated by the provider's RBAC permission 2026-09-21 17:09:28 -05:00
LukeGus 2d6c1cdecb feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
2026-09-21 16:56:15 -05:00
LukeGus 225f618337 feat: add a permission-gated backend service registry for plugins 2026-09-21 16:23:00 -05:00
LukeGus ee2d6b4697 fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
2026-09-21 15:25:17 -05:00
LukeGus 504710b065 feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
2026-09-21 15:20:05 -05:00
LukeGus c58ccbfc3f fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
2026-09-21 14:32:59 -05:00
LukeGus 19b70430a1 feat: ship host metrics as a first-party in-process plugin 2026-09-21 11:30:46 -05:00
LukeGus a2479c19b0 feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
2026-09-21 09:55:37 -05:00
LukeGus c94a453f1e feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
2026-09-21 08:28:50 -05:00
LukeGus e040b12045 fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
2026-09-21 07:23:48 -05:00
LukeGus 4e1dec8435 feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
2026-09-21 07:19:02 -05:00
LukeGus 4311a0e98a feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
2026-09-21 07:19:01 -05:00
LukeGus 1393fa7581 feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
2026-09-21 07:19:01 -05:00
LukeGus 0a771c9600 feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
2026-09-21 07:19:01 -05:00
LukeGus 9f4a2d3951 feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
2026-09-21 07:19:01 -05:00
LukeGus 248698f62d feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
2026-09-21 07:19:01 -05:00
dependabot[bot] a1805cb7ae chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:27:00 -05:00
dependabot[bot] f92ad3a3f7 chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:25:56 -05:00
dependabot[bot] ae011e6282 ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:25:17 -05:00
LukeGus 7f1beddccc feat: add category and icon fields to plugin manifest schema 2026-09-20 21:06:16 -05:00
LukeGus 5a76d30637 chore: increment ver 2026-09-20 21:00:38 -05:00
LukeGus 6b56accec8 fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
2026-09-20 20:24:43 -05:00
LukeGus f694023a65 fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
2026-09-20 19:46:04 -05:00
LukeGus beb5145475 fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
2026-09-20 19:12:34 -05:00
LukeGus a9a3bbc5f4 fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
2026-09-20 19:06:59 -05:00
LukeGus 7727fa09fb fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
2026-09-20 19:06:54 -05:00
LukeGus e9fd092ebd fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
2026-09-20 19:06:07 -05:00
LukeGus afae2889cb feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
2026-09-20 18:59:33 -05:00
LukeGus f8a8139031 feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
2026-09-20 18:53:38 -05:00
LukeGus 4052d63594 feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
2026-09-20 18:48:40 -05:00
LukeGus 0b7e616419 feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
2026-09-20 18:17:57 -05:00
LukeGus d0c25cf183 feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
2026-09-20 18:17:29 -05:00
LukeGus 6ce5e78165 feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
2026-09-20 18:16:45 -05:00
LukeGus 42e7861073 feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
2026-09-20 18:15:45 -05:00
LukeGus 2c8d13323b feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
2026-09-20 18:12:35 -05:00
LukeGus da840b925c feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
2026-09-20 18:12:10 -05:00
LukeGus 6837144ad5 feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
2026-09-20 18:10:56 -05:00
LukeGus db2c90838f feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
2026-09-20 18:10:13 -05:00
Koi 0ab6a3e674 Fix/backend optional sharp (#1455)
Fix/backend optional sharp
2026-09-20 17:58:29 -05:00
Koi d603d560c3 fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
2026-09-20 17:58:20 -05:00
253 changed files with 2087 additions and 17953 deletions

No files matched your search

+44 -21
View File
@@ -5,22 +5,13 @@ yarn-error.log*
dist
build
coverage
release
.next
.nuxt
# Local data, secrets and editor state
db
old_db
ssl
uploads
plugins
.env
.env.*
.mcp.json
.claude
CLAUDE.md
termix-plugin-signing.key
*.tmxplug.sig
.env.local
.env.development.local
.env.test.local
.env.production.local
.vscode
.idea
@@ -38,31 +29,63 @@ Thumbs.db
.git
.gitignore
.husky
README.md
CONTRIBUTING.md
CHANGELOG.md
LICENSE
uploads/
electron/
electron-builder.json
Casks
packaging
*.log
*.tmp
*.temp
logs
*.log
pids
*.pid
*.seed
*.pid.lock
coverage
.nyc_output
jspm_packages/
.npm
.eslintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
.cache
.parcel-cache
*.tgz
.node_repl_history
.next
.nuxt
.vuepress/dist
.serverless
.fusebox/
.dynamodb/
.tern-port
+6 -13
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 26.10.0)"
description: "Version to build (e.g., 1.8.0)"
required: true
build_type:
description: "Build type"
@@ -22,11 +22,11 @@ on:
workflow_call:
inputs:
version:
description: "Version to build (e.g., 26.10.0)"
description: "Version to build (e.g., 1.8.0)"
required: true
type: string
build_type:
description: "Build type (Development, Beta or Production)"
description: "Build type (Development or Production)"
required: true
type: string
dry_run:
@@ -52,14 +52,7 @@ jobs:
fetch-depth: 1
- name: Resolve source revision
run: |
echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
echo "BUILD_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
# The image reads its version from package.json, so a beta has to say so.
- name: Stamp beta version
if: ${{ inputs.build_type == 'Beta' }}
run: node scripts/sync-version.cjs --version "${{ inputs.version }}"
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -81,7 +74,7 @@ jobs:
ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION" "latest")
TAGS+=("release-$VERSION" "$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION")
else
@@ -127,7 +120,7 @@ jobs:
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
org.opencontainers.image.created=${{ env.BUILD_CREATED }}
org.opencontainers.image.created=${{ github.run_id }}
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
- name: Login to Docker Hub as termixssh
+7 -23
View File
@@ -117,11 +117,6 @@ jobs:
- name: Install dependencies
run: npm ci
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Get version
id: package-version
run: |
@@ -225,11 +220,6 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Build Linux x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -439,11 +429,6 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Check for Code Signing Certificates
id: check_certs
run: |
@@ -483,15 +468,14 @@ jobs:
security find-identity -v -p codesigning $KEYCHAIN_PATH
# Betas never go to the App Store, so they skip the MAS package.
- name: Build macOS App Store Package
id: mas_build
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
if: steps.check_certs.outputs.has_certs == 'true'
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
@@ -550,7 +534,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
npm run build
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
@@ -571,7 +555,7 @@ jobs:
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
- name: Upload macOS MAS PKG
if: steps.mas_build.outcome == 'success' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
if: steps.check_certs.outputs.has_certs == 'true' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7
with:
name: termix_macos_universal_mas
@@ -738,7 +722,7 @@ jobs:
choco pack termix-ssh.nuspec
if ($LASTEXITCODE -ne 0) {
throw "Chocolatey pack failed with exit code $LASTEXITCODE"
throw "Chocolatey push failed with exit code $LASTEXITCODE"
}
- name: Check for Chocolatey API Key
@@ -968,8 +952,8 @@ jobs:
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
- name: Verify Cask syntax
run: |
+65 -13
View File
@@ -253,7 +253,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 }}"
sync-main:
crowdin:
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,27 +265,79 @@ 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
# -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
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
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, sync-main]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
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') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
+2 -19
View File
@@ -4,31 +4,14 @@
### Added
- Plugins. Every feature beyond hosts, credentials and sharing is now its own plugin with its own repo, and you pick which ones you want
- A Plugins tab to install, update, pin, turn off and remove plugins from the official registry, with release notes and a beta channel per plugin
- Developer mode to install a plugin from a file, and a plugin SDK on npm to build your own
- The community plugin registry is open for submissions. Termix can install community plugins in a later update
- Redesigned UI with a Manage tab for hosts and credentials and a Settings tab with search
- New full screen onboarding where you choose which plugins to start with
- Beta channel for Termix itself in Settings > Updates
- Temp
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
- Report a bug links that open a prefilled issue in the right repo
- `TRUSTED_PROXIES` to choose which proxies nginx takes client IPs from
### Changed
- Termix now uses calendar versions (year, month, patch), starting with 26.10.0
- Redesigned host defaults with admin, user and folder levels
- Simpler host status: online or offline, kept live without a refresh
- Bug reports and feature requests now go to each repo instead of the Support repo
### Fixed
- A lot of bugs across the app, the desktop app and every plugin
- Several security issues reported through GitHub advisories
- Tem
### Removed
- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading
- The donation reminder popup
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.9.2"
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
version "2.9.1"
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
-1
View File
@@ -1,4 +1,3 @@
Copyright 2026 Luke Gustafson
Apache License
Version 2.0, January 2004
+6 -10
View File
@@ -92,7 +92,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).
<br />
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr>
<tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable · EXE and MSI Installer · Chocolatey</td>
<td>Portable · MSI Installer · Chocolatey</td>
</tr>
<tr>
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></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 align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td>
<td>Apple App Store · DMG · Homebrew</td>
</tr>
<tr>
@@ -149,10 +149,6 @@ 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:
@@ -162,8 +158,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
ports:
- "4822:4822"
networks:
- termix-net
+2 -2
View File
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/ui/index.css",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@@ -15,7 +15,7 @@
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
+9
View File
@@ -0,0 +1,9 @@
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
+4 -4
View File
@@ -1,7 +1,7 @@
# Stage 1: Install dependencies
# The builders only produce JS, so they run on the build machine's own arch
# once instead of under QEMU per target. Rolldown hangs under arm64 emulation.
FROM --platform=$BUILDPLATFORM node:24-slim AS deps
FROM --platform=$BUILDPLATFORM node:26-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -37,7 +37,7 @@ COPY . .
RUN 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
FROM node:26-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only
FROM node:24-slim AS production-deps
FROM node:26-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 6: Final optimized image
FROM node:24-slim
FROM node:26-slim
WORKDIR /app
# The release the opkssh-downloader stage baked in, so the plugin trusts that
+46 -60
View File
@@ -4,162 +4,152 @@
"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": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
"sha256": "1725735a058065b211436b166c0ef7d4ca6e74438102764664381160a2b305d3"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
"sha256": "ea121658305bed9d28e046e096fc458976258d7cb77387e1b8ac82ccb9a001cb"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
"sha256": "6cb00cf9464e42dbc46786a6ef74b07d62da39ece9905197584d2934265b9b10"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"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": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
"sha256": "4a774d4a3685b0ee8da5b73d14c60d40f10b6a3f1c5b31a5dfefb94ba30fb7ad"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
"sha256": "403513bc1f96a7c21d39d55c8fd1d4e55fde7b8e6ec63228839bf3243d93e68a"
},
{
"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": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
"sha256": "2d8cb269242c12522a5195fa44ec14e95ade3b00d7e7be12e9204631518a0f88"
},
{
"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": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
"sha256": "5c0a585dcffd1cd523e94d45f2eda32800c403a4c832854fec7331b651da7b7d"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
"sha256": "7e8a17aa9053954e937ac2274928e29b821dfd671759e356551b27c13e23c9ad"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
"sha256": "cb6149ab69e348e5a0a1939b31943cb64aded3e1ee930b91dc77c46a715990d0"
},
{
"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": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"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": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
"sha256": "b6fbe38cd6d62d0b361c102f13f050ee8a281983dc806e32e8d8feeb416ceb20"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
"onboarding": {
"recommended": "desktop",
"consent": false
"recommended": "desktop"
}
},
{
"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": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
"sha256": "ff0e6948806e0127399d41a6abd0f41fce404e65ba2c9d8044fb6dced401f4bb"
},
{
"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": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
"sha256": "4d2e363086a8fe79815bf76b4ed359ac82d5926373ab24854028e4dbc5a57ac6"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"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": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
@@ -167,19 +157,19 @@
"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": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
"sha256": "997af2068f88c017c76a057683ac5e367566b5b8bc0961fc1e29c32b702a9059"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
"sha256": "963a84859f8411bcb27e2950d46a7a43b239ace1295ac2abc4a77ecec43806d9"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
"onboarding": {
"recommended": true,
"consent": true
@@ -189,76 +179,72 @@
"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": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
"sha256": "00dfe3da65f78d7d085817c93a4e01bfef6931fb70ff5576c76e2f641e337555"
},
{
"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": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
"sha256": "bbc508410296ca3434b5ba8c40fa5ef87646d104ec6f75da3bf0e22313bbbf18"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
"sha256": "d84b0ef1a309bb3a89288f488a242dd19df9e46b237df778c2f2a77e77867960"
},
{
"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": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
"sha256": "e680c22279438a566d1033939e4a3063a0fbbeb7f57b6db3bd6712bed09a4388"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
"sha256": "c974be686488905277a52440b248d14370f3f543a7bd53f3485d5b6db4fab40b"
},
{
"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": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
"sha256": "e93d8535617783bc538d415f25a5acc04e3805417002d68d4ebc4044c8fbed8c"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
},
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
"onboarding": {
"recommended": true,
"consent": false
"recommended": true
}
}
]
@@ -1 +0,0 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -491,13 +491,6 @@
"when": 1791388888997,
"tag": "0069_plugin_channel",
"breakpoints": true
},
{
"idx": 70,
"version": "5",
"when": 1791550497926,
"tag": "0070_drop_donation_modal",
"breakpoints": true
}
]
}
@@ -1 +0,0 @@
ALTER TABLE "users" DROP COLUMN "donation_modal_dismissed";
File diff suppressed because it is too large. Load diff
-7
View File
@@ -491,13 +491,6 @@
"when": 1791388888397,
"tag": "0069_plugin_channel",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1791550496661,
"tag": "0070_drop_donation_modal",
"breakpoints": true
}
]
}
@@ -1 +0,0 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -456,13 +456,6 @@
"when": 1791388887785,
"tag": "0064_plugin_channel",
"breakpoints": true
},
{
"idx": 65,
"version": "6",
"when": 1791550495357,
"tag": "0065_drop_donation_modal",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -123,7 +123,7 @@
"entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
"minimumSystemVersion": "12.0",
"minimumSystemVersion": "10.15",
"mergeASARs": false,
"singleArchFiles": "**/*.{node,bare,dylib}",
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
+2 -2
View File
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
backendCwd: backendDir,
};
}
// fork() does not go through Electron's asar redirector, use the unpacked path.
// fork() does not go through Electron's asar redirector — use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot);
@@ -1303,7 +1303,7 @@ function createTray() {
try {
const { nativeImage } = require("electron");
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path.join(appRoot, "public")
+3 -3
View File
@@ -53,7 +53,7 @@ export default tseslint.config([
},
{
// MySQL has no RETURNING clause, and drizzle's mysql-core does not expose
// the method at all. A bare .returning() is a TypeError there, not a bad
// the method at all — a bare .returning() is a TypeError there, not a bad
// query, and it only fails on the engine no test in this repo runs against.
//
// 175 call sites were migrated off it. This is what stops number 176.
@@ -85,14 +85,14 @@ export default tseslint.config([
{
// Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON
// DUPLICATE KEY and names no columns, so drizzle's mysql-core has no
// onConflictDoUpdate at all, another TypeError, not a bad query.
// onConflictDoUpdate at all — another TypeError, not a bad query.
selector: "CallExpression[callee.property.name='onConflictDoUpdate']",
message:
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
},
{
// better-sqlite3 puts these on a write result; node-postgres and
// mysql2 do not, so reading them directly yields undefined, and
// mysql2 do not, so reading them directly yields undefined — and
// Number(undefined) is NaN, which reaches the database as the string
// "NaN" and fails an integer column. Three call sites did exactly
// this and only broke on Postgres.
+29
View File
@@ -20,6 +20,35 @@
<link rel="manifest" href="manifest.json" />
<title>Termix</title>
<style>
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.skinny-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4a4a4a #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.skinny-scrollbar::-webkit-scrollbar-track {
background: #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar-thumb {
background-color: #4a4a4a;
border-radius: 3px;
border: 1px solid #1e1e21;
}
.toolbar-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4a4a4a transparent;
+2 -14
View File
@@ -12,7 +12,6 @@
"packages/*"
],
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -2877,6 +2876,7 @@
"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"
@@ -15015,7 +15015,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.5",
"version": "1.1.0",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -15054,21 +15054,9 @@
"drizzle-orm": {
"optional": true
},
"i18next": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
},
"ssh2": {
"optional": true
},
-1
View File
@@ -47,7 +47,6 @@
"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",
-11
View File
@@ -1,16 +1,5 @@
# Changelog
## 1.0.5
### Fixed
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
## 1.0.4
### Fixed
-1
View File
@@ -28,7 +28,6 @@ npm install --save-dev @termix-ssh/plugin-sdk
```bash
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin patch # apply the plugin's dependency patches (build does this too)
npx termix-plugin test # run the plugin's Vitest suite
npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
+7 -7
View File
@@ -108,6 +108,13 @@ const HOST_DIR = path.join(
const THEME_CSS = path.join(HOST_DIR, "theme.css");
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
/**
* Moves a plugin's Tailwind layers under their own names. Core orders
* termix-plugin-utilities above its base styles and below its own utilities,
@@ -129,13 +136,6 @@ function readCoreClasses() {
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
}
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
@@ -17,7 +17,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
external: BACKEND_EXTERNALS,
});
const module = await import(pathToFileURL(outfile).href);
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
const tables = module.tables ?? module.default;
if (!Array.isArray(tables)) {
throw new Error(
@@ -218,11 +218,6 @@ async function validateMigrations(cwd, pluginId) {
const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)),
);
for (const dialect of DIALECTS) {
if (present.length > 0 && !present.includes(dialect)) {
problems.push(`migrations/${dialect} is missing`);
}
}
const byDialect = new Map();
for (const dialect of present) {
+1 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.5",
"version": "1.0.4",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -148,18 +148,6 @@
},
"@testing-library/react": {
"optional": true
},
"i18next": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
}
},
"publishConfig": {
+2 -13
View File
@@ -194,9 +194,7 @@ export function parseChangelog(markdown: string): {
/**
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
* be that version, so a release never ships without its notes. A beta like
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
* to be 1.1.0 or older.
* be that version, so a release never ships without its notes.
*/
export function validateChangelog(
markdown: string,
@@ -208,16 +206,7 @@ export function validateChangelog(
return problems;
}
const newest = changelog.releases[0].version;
const base = manifestVersion
? semver.valid(semver.coerce(manifestVersion))
: null;
const betaOk =
!!manifestVersion &&
!!semver.prerelease(manifestVersion) &&
!!base &&
!!semver.valid(newest) &&
semver.lte(newest, base);
if (manifestVersion && newest !== manifestVersion && !betaOk) {
if (manifestVersion && newest !== manifestVersion) {
problems.push(
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
);
+1 -1
View File
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
export const id = () =>
builder({ type: "id", primaryKey: true, notNull: true });
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
export const text = () => builder({ type: "text" });
/** Bounded string. Indexable on every engine. */
+1 -3
View File
@@ -80,13 +80,11 @@ function columnType(
// though it was declared unbounded. defineTable refuses to index a
// text/json column, so in practice this only catches a primary key.
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
return "text";
}
}
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
/** Column types that are TEXT on MySQL when nothing keys them. */
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
"text",
"json",
+1 -1
View File
@@ -32,7 +32,7 @@ export function execCommand(
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms`));
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
+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, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, 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>>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-7
View File
@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 528 B

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

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
<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"/>
<?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>

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+126
View File
@@ -0,0 +1,126 @@
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
@@ -0,0 +1,21 @@
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);
});
});
+8 -8
View File
@@ -7,8 +7,8 @@
* drizzle/mysql. Nothing imports them to run a query.
*
* That is not an oversight. The query builder needs two things from a table
* object: the identifiers to interpolate, and the encoders that turn JS values
* into driver values. The sqlite definitions supply both correctly for
* object — the identifiers to interpolate, and the encoders that turn JS values
* into driver values — and the sqlite definitions supply both correctly for
* every engine, which is why all 44 repositories import schema.ts directly:
*
* - text and integer encode as themselves everywhere
@@ -17,15 +17,15 @@
* which is true for JS `true` as well as for 1
* - real is a plain number on all three
*
* What genuinely differs between the dialects is DDL (column types, key
* lengths, autoincrement syntax), and DDL is exactly what these files exist to
* What genuinely differs between the dialects is DDL — column types, key
* lengths, autoincrement syntax — and DDL is exactly what these files exist to
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips
* above against real servers rather than trusting this comment.
*
* The schema is declared once, in sqlite-core, and the other two dialects are
* derived. Hand-maintaining three copies of 52 tables would mean a renamed
* table has to land in three places consistently or a foreign key silently
* points at the wrong one, and the schema is regular enough that the mapping
* points at the wrong one — and the schema is regular enough that the mapping
* is mechanical.
*
* What varies between dialects is small and closed:
@@ -198,7 +198,7 @@ function transform(source, dialect) {
);
// Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column. On a TEXT
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT
// one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an
// expression default works on any type, and an expression is written
// parenthesised. MariaDB accepts the bare form, which is why this only
@@ -272,13 +272,13 @@ function transform(source, dialect) {
}
function header(dialect) {
return `// GENERATED FILE, do not edit.
return `// GENERATED FILE — do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
// Target dialect: ${dialect}.
//
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
`;
}
+1 -1
View File
@@ -137,7 +137,7 @@ describe("mysql output", () => {
expect(out).toContain('double("score")');
});
it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => {
expect(out).toContain('varchar("user_id", { length: 255 })');
expect(out).toContain('text("name")');
});
+99 -73
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,98 +10,124 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
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 svgBuffer = readFileSync(join(publicDir, "icon.svg"));
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)])),
);
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],
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`)),
),
);
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
// 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()),
);
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, "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.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"));
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(" ✓ icons/icon.ico");
console.log("Icons generated from public/icon-square.svg");
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
/**
* 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;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
const entries = pngBuffers.map((buf, i) => {
const entry = { size: sizes[i], buf, offset };
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
offset += buf.length;
return entry;
});
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
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
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
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);
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
});
// 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]);
}
+6 -6
View File
@@ -28,7 +28,7 @@ function fail(message) {
function buildTable(version, mobileVersion, mobileTag) {
const tag = `v${version}`;
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `v${mobileVersion}`}`;
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`;
const win = (file) => `${base}/${file}`;
const linux = (file) => `${base}/${file}`;
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
return [
`| 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) |`,
`| **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) | - | - |`,
`| **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) |`,
`| **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/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/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/connector/macos) | — | — |`,
].join("\n");
}
+1 -1
View File
@@ -30,7 +30,7 @@ describe("buildBody", () => {
expect(body).not.toContain("A bug");
expect(body).toContain("/releases/download/v26.10.0/termix_");
expect(body).toContain(
"Mobile/releases/download/v1.2.0/termix_android.apk",
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk",
);
});
-11
View File
@@ -131,17 +131,6 @@ describe("validateChangelog", () => {
);
});
it("lets a beta through when its notes are not written yet", () => {
expect(validateChangelog(FILE, "1.1.0-beta.1")).toEqual([]);
expect(validateChangelog(FILE, "1.2.0-beta.3")).toEqual([]);
});
it("flags a beta older than the newest release", () => {
expect(validateChangelog(FILE, "1.0.1-beta.1")).toContain(
"the newest release is 1.1.0, but the manifest version is 1.0.1-beta.1",
);
});
it("flags a file with no releases", () => {
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
'no releases found, add a "## 1.0.0" heading',
-51
View File
@@ -1,51 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { validate } from "../packages/plugin-sdk/cli/commands/validate.mjs";
const roots: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0))
fs.rmSync(root, { recursive: true, force: true });
});
function plugin(dialects: string[]) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-validate-"));
roots.push(root);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({ id: "fixture", version: "1.0.0" }),
);
for (const dialect of dialects) {
const dir = path.join(root, "migrations", dialect);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "0001_init.sql"),
"CREATE TABLE p_fixture_items (id INTEGER PRIMARY KEY);\n",
);
}
return root;
}
async function problems(cwd: string) {
const lines: string[] = [];
vi.spyOn(console, "error").mockImplementation((line) => lines.push(line));
vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(validate({ cwd })).rejects.toThrow();
return lines.join("\n");
}
describe("termix-plugin validate migrations", () => {
it("flags an engine with no migrations folder", async () => {
const output = await problems(plugin(["sqlite", "postgres"]));
expect(output).toContain("migrations/mysql is missing");
expect(output).not.toContain("migrations/postgres is missing");
});
it("accepts all three engines", async () => {
const output = await problems(plugin(["sqlite", "postgres", "mysql"]));
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
+5 -5
View File
@@ -2,8 +2,8 @@
* Runs the repository layer against a real Postgres or MySQL server.
*
* The unit tests only ever see SQLite, so the parts of this codebase that
* differ per engine (the RETURNING replacements, the read-then-write
* transactions, the value encoders) have no coverage there at all. This is
* differ per engine — the RETURNING replacements, the read-then-write
* transactions, the value encoders — have no coverage there at all. This is
* what covers them, and it needs a live server, which is why it is a script
* rather than a test.
*
@@ -12,7 +12,7 @@
* npm run verify:dialect -- mysql://user:pass@host:3306/db
*
* Applies the migrations first, through the same runRemoteMigrations() the
* application uses at startup, so a broken migration fails here rather than in
* application uses at startup — so a broken migration fails here rather than in
* production. Writes real rows: point it at a scratch database.
*/
@@ -40,7 +40,7 @@ const { drizzle } = await import(
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
);
// No schema option on purpose, see connect.ts.
// No schema option on purpose — see connect.ts.
const db = drizzle(url);
const context = { dialect, drizzle: db };
@@ -87,7 +87,7 @@ check("insert returns the stored row", created?.username, "before");
check("boolean true survives the round trip", created?.isAdmin, true);
// updateReturning must report the state AFTER the write. Reading first would
// return the value the update replaced, silently, with no error.
// return the value the update replaced — silently, with no error.
const updated = await users.update(userId, { username: "after" });
check("update returns the new value", updated?.username, "after");
-22
View File
@@ -171,27 +171,6 @@ async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
/**
* An account that only signs in through SSO or LDAP must keep doing so. A
* passkey or any other local method would skip the identity provider, so a
* user disabled there could still get in.
*/
export function assertMethodAllowedFor(
user: Pick<UserRecord, "isExternal" | "passwordHash">,
identity: VerifiedIdentity,
methodId: string,
): void {
if (identity.kind === "external" || !user.isExternal) return;
if (user.passwordHash && user.passwordHash.trim() !== "") return;
ensureCoreLoginProviders();
if (getLoginMethod(methodId)?.external) return;
throw new LoginMethodError(
"This account signs in through its identity provider",
403,
"external_login_required",
);
}
async function resolveUser(
identity: VerifiedIdentity,
deviceType: DeviceType,
@@ -274,7 +253,6 @@ export async function runLogin(
): Promise<LoginResult> {
const deviceType = parseUserAgent(req).type;
const user = await resolveUser(identity, deviceType);
assertMethodAllowedFor(user, identity, context.methodId);
await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) {
+1 -1
View File
@@ -275,7 +275,7 @@ export async function findOrProvisionExternalUser(
provider: identity.provider,
});
throw new LoginMethodError(
"You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
"Registration is disabled",
403,
"registration_disabled",
);
+3 -3
View File
@@ -268,7 +268,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* localVersion:
* type: string
* description: Version of the running instance. Use this field for client compatibility checks.
* example: 26.10.0
* example: 2.7.1
* status:
* type: string
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* remoteVersion:
* type: string
* description: Newest release version on the channel. Present only when the update check succeeds.
* example: 26.10.0
* example: 2.8.0
* version:
* type: string
* deprecated: true
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
* example: 26.10.0
* example: 2.8.0
* latest_release:
* type: object
* description: GitHub release metadata. Present only when the update check succeeds.
+3 -3
View File
@@ -50,7 +50,7 @@ export function sslOption(
/**
* Opens a connection to a client-server engine.
*
* SQLite is not handled here, it has its own lifecycle in db/index.ts, where
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where
* the database is decrypted into memory and serialised back to a file. This
* covers the engines that connect to something already running.
*
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
// No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import,
// names and value encoders from the table objects the repositories import —
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres");
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
}
// mysql2 names the pool limit differently and wants no `ssl` key at all when
// TLS is off, passing false is not the same as omitting it.
// TLS is off — passing false is not the same as omitting it.
const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle({
connection: {
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
*
* SQLite here is an in-memory database serialised back to an encrypted file, so
* every write needs a trigger to flush it. Client-server engines have already
* durably committed by the time the query returns, there is no file to write
* durably committed by the time the query returns — there is no file to write
* and nothing to schedule.
*/
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
+13 -4
View File
@@ -242,7 +242,8 @@ async function initializeCompleteDatabase(): Promise<void> {
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
@@ -969,7 +970,9 @@ const migrateSchema = () => {
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
addColumnIfNotExists("users", "registered_at", "TEXT");
if (!hadRegisteredAtColumn) {
// Pre-existing users get a date in the past, since their real one is unknown.
// Pre-existing users are backdated past the 30 day mark so they see the
// donation modal immediately on upgrade instead of waiting a fresh
// 30 days as if they had just registered.
try {
sqlite.exec(
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
@@ -997,6 +1000,12 @@ const migrateSchema = () => {
);
}
}
addColumnIfNotExists(
"users",
"donation_modal_dismissed",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("ssh_data", "name", "TEXT");
@@ -1094,7 +1103,7 @@ const migrateSchema = () => {
// Derive the replacement table from the live definition instead of
// restating it here. The table keeps gaining columns (cert_public_key,
// pin, sort_order, sync_id, ...), and a second copy of the column list
// falls behind every time one is added, leaving the copy narrower than
// falls behind every time one is added — leaving the copy narrower than
// the table, so the INSERT below fails and the constraint stays put.
const createSql = sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
@@ -2099,7 +2108,7 @@ export function getSqlite(): Database.Database {
if (dialect !== "sqlite") {
throw new Error(
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
`synchronous query, which only SQLite offers. Give it an async path instead.`,
`synchronous query, which only SQLite offers — give it an async path instead.`,
);
}
throw new Error(
+1 -1
View File
@@ -25,7 +25,7 @@ export function migrationsFolder(
* Brings a remote database up to the current schema.
*
* drizzle's migrator records what it has applied in its own table, so this is
* safe to run on every start, including against a database another instance
* safe to run on every start — including against a database another instance
* already migrated.
*/
export async function runRemoteMigrations(
@@ -77,7 +77,6 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
"identifier_path",
"name_path",
"scopes",
"donation_modal_dismissed",
],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
user_preferences: [
+8 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE, do not edit.
// GENERATED FILE — do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: mysql.
//
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
import {
@@ -27,6 +27,9 @@ export const users = mysqlTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = mysqlTable("settings", {
@@ -539,7 +542,7 @@ export const sharedHostSecrets = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -589,7 +592,7 @@ export const userRoles = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1057,7 +1060,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* install/uninstall actions themselves — kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = mysqlTable(
+8 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE, do not edit.
// GENERATED FILE — do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: postgres.
//
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
import {
@@ -27,6 +27,9 @@ export const users = pgTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = pgTable("settings", {
@@ -539,7 +542,7 @@ export const sharedHostSecrets = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -589,7 +592,7 @@ export const userRoles = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1057,7 +1060,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* install/uninstall actions themselves — kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = pgTable(
+8 -3
View File
@@ -15,6 +15,11 @@ export const users = sqliteTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: integer("donation_modal_dismissed", {
mode: "boolean",
})
.notNull()
.default(false),
});
export const settings = sqliteTable("settings", {
@@ -529,7 +534,7 @@ export const sharedHostSecrets = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -579,7 +584,7 @@ export const userRoles = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids, and the upsert had nothing to conflict on.
// SQLite deployment forbids — and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1053,7 +1058,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* install/uninstall actions themselves — kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = sqliteTable(
+2 -3
View File
@@ -3,8 +3,7 @@
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
* database has to pass through 2.9 first, or that data is lost.
*
* 2.8 already had a plugins table, so the marker is plugin_migrations, which
* 2.9.0 added on every dialect. A 2.8 database has hosts but not that.
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
*/
export const PRE_29_DATABASE_ERROR =
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
@@ -12,7 +11,7 @@ export const PRE_29_DATABASE_ERROR =
export async function assertNotPre29Database(
hasTable: (name: string) => boolean | Promise<boolean>,
): Promise<void> {
if ((await hasTable("ssh_data")) && !(await hasTable("plugin_migrations"))) {
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
throw new Error(PRE_29_DATABASE_ERROR);
}
}
@@ -74,7 +74,7 @@ export class AuditLogRepository {
/**
* Cached row count backing the cap check. Static because the factory builds
* a repository per call, so a per-instance count would never survive to be
* reused. Null means "unknown, re-read", which is also how any path that
* reused. Null means "unknown, re-read" — which is also how any path that
* deletes rows invalidates it.
*/
private static cachedCount: number | null = null;
@@ -233,13 +233,13 @@ export class AuditLogRepository {
*
* Both passes used to run inline on every insert: a retention DELETE plus a
* COUNT over the whole table, thousands of times an hour on a busy install,
* almost always to find nothing to do, and audit writes sit in the request
* almost always to find nothing to do — and audit writes sit in the request
* path of the actions they record.
*
* They are split by what they cost and what they guarantee. Retention is
* time-based, so nothing is lost by checking it on an interval. The row cap
* is a disk-space guard that has to react to inserts, so it is still checked
* on the write that crosses it, but against a cached count, so the common
* on the write that crosses it — but against a cached count, so the common
* case is an integer compare rather than a COUNT.
*/
private async pruneIfDue(): Promise<void> {
@@ -152,15 +152,6 @@ export class CredentialAccessRepository {
return rows[0] ?? null;
}
/** Credentials holding a share this user handed out. */
async listCredentialIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ credentialId: credentialAccess.credentialId })
.from(credentialAccess)
.where(eq(credentialAccess.grantedBy, userId));
return rows.map((row) => row.credentialId);
}
async revoke(accessId: number, credentialId: number): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts, a local copy that said "sqlite" survived here for a while and
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js";
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
*
* This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed,
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
* identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance.
*
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
*
* Deliberately drizzle-only: with no raw driver handle here, no repository can
* reach for engine-specific SQL. Retention queries that previously needed
* `datetime('now', ?)` compute their cutoff in JS instead, see
* `datetime('now', ?)` compute their cutoff in JS instead — see
* ./sql-timestamp.ts.
*/
export interface DatabaseContext {
+5 -5
View File
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
* write.
*
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function, see tests/database/repositories/factory-context.
* exercises this function — see tests/database/repositories/factory-context.
*/
export function createCurrentRepositoryContext(): DatabaseContext {
return {
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
* Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does
* nothing, repositories call it as `this.onWrite?.()`.
* nothing — repositories call it as `this.onWrite?.()`.
*/
export function createCurrentRepositoryWriteHook(
reason: string,
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
}
/**
* Raw driver handle for the few synchronous call sites that cannot await,
* Raw driver handle for the few synchronous call sites that cannot await —
* getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first.
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
*
* The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and
* MySQL, which exist here precisely so more than one instance can share the
* data, a setting changed on one replica would otherwise never reach the
* MySQL — which exist here precisely so more than one instance can share the
* data — a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database.
*
* Periodic re-priming does not make the value immediately consistent. It bounds
@@ -31,7 +31,7 @@ export class HostFolderRepository {
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to
// matter here, MySQL has no `text` cast and wants `char`.
// matter here — MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
* Reading the outcome of a write without depending on RETURNING.
*
* SQLite and Postgres can attach `.returning()` to a delete or update and get
* the affected rows back. **MySQL cannot**: it has no RETURNING clause, and
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and
* drizzle's mysql-core does not expose the method at all, so the call is a
* TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere.
*
* The split that matters is what the caller actually needs:
*
* - **How many rows changed**: the majority, and none of them need the rows.
* - **How many rows changed** — the majority, and none of them need the rows.
* They used to ask for them anyway, via `.returning().length`. Dropping the
* `.returning()` and reading the driver's own count is both portable and one
* less thing for the database to send back.
* - **The rows themselves**: cannot be emulated on MySQL without reading
* - **The rows themselves** — cannot be emulated on MySQL without reading
* first, which needs a transaction to stay correct under concurrency. Those
* call sites are handled individually rather than behind a helper that hides
* an extra round trip.
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* | node-postgres | `{ rowCount, rows, command }` |
* | mysql2 | `[{ affectedRows, insertId }, fields]` |
*
* These are the shapes returned when NO `.returning()` is attached, which is
* These are the shapes returned when NO `.returning()` is attached — which is
* the portable way to write, since MySQL has no RETURNING clause at all.
*/
interface WriteHeader {
@@ -47,7 +47,7 @@ interface WriteHeader {
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
/**
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array, so
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so
* "is it an array" cannot distinguish a write header from a returning() result.
* The header is identified by carrying one of the fields above instead.
*/
@@ -70,7 +70,7 @@ function asWriteHeader(result: unknown): WriteHeader | null {
/**
* Number of rows a write touched.
*
* Pass the result of the write itself, every driver's header is understood, so
* Pass the result of the write itself — every driver's header is understood, so
* the caller neither branches on the dialect nor attaches `.returning()` just to
* count what came back.
*
@@ -97,7 +97,7 @@ export function rowsAffected(result: unknown): number {
*
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
* MySQL's `insertId` are connection-level values that survive the statement that
* set them, after a delete, SQLite still reports whatever the last insert
* set them — after a delete, SQLite still reports whatever the last insert
* produced. Passing an update or delete result here gets a stale id, not null.
*
* Returns null when the table has no autoincrement key.
@@ -143,7 +143,7 @@ export function supportsReturning(dialect: DatabaseDialect): boolean {
*
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
* as bigint, which node-postgres hands back as a **string** so that values past
* 2^53 survive, so the annotation is a lie there and comparisons like
* 2^53 survive — so the annotation is a lie there and comparisons like
* `count < max` compare a string to a number.
*/
export function countValue(value: unknown): number {
@@ -173,15 +173,6 @@ export class RbacAccessRepository {
return rowsAffected(result);
}
/** Hosts holding a share this user handed out. */
async listHostIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ hostId: hostAccess.hostId })
.from(hostAccess)
.where(eq(hostAccess.grantedBy, userId));
return rows.map((row) => row.hostId);
}
/** Grants the departing user handed out now count as the successor's. */
async reassignHostAccessGrantedBy(
fromUserId: string,
+12 -12
View File
@@ -11,15 +11,15 @@ import {
* Writes that need the affected rows back.
*
* `mutation-result.ts` covers the call sites that only wanted a count. These are
* the ones that genuinely read the rows, an updated record to return to the
* the ones that genuinely read the rows — an updated record to return to the
* caller, a deleted row's fields to clean up alongside it.
*
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no
* such clause, so the read is a second statement, and the pair has to be atomic:
*
* - **insert**: write, then read the row back by its key.
* - **update**: write, then read. Reading first would return the old values.
* - **delete**: read, then write. Reading after would return nothing.
* - **insert** — write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing.
*
* Both run in a transaction. Without one, a concurrent write between the two
* statements makes the returned rows describe a state that never existed, and
@@ -29,7 +29,7 @@ import {
* ## The trap, and why it cannot bite silently
*
* On MySQL the update path re-reads using the same `where`. If the update
* changes a column that `where` tests, the read finds nothing, SQLite would
* changes a column that `where` tests, the read finds nothing — SQLite would
* have returned the row. Every current caller filters on an id it does not
* modify, but that is a convention, not a guarantee, so the mismatch is
* detected and thrown rather than returned as an empty array. Same for an
@@ -40,7 +40,7 @@ import {
*/
/**
* What `.set()` accepts: a column's own type, or a SQL expression in its place,
* What `.set()` accepts: a column's own type, or a SQL expression in its place —
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
*/
type UpdateValues<T extends SQLiteTable> = {
@@ -68,7 +68,7 @@ export async function updateReturning<T extends SQLiteTable>(
const rows = await tx.select().from(table).where(where);
// The trap this catches: if the update changed a column that `where` tests,
// the read finds nothing and the caller gets [], on MySQL only, with no
// the read finds nothing and the caller gets [] — on MySQL only, with no
// error, where SQLite would have returned the row. Rows changed but none
// readable back is exactly that case, so make it loud instead.
if (rows.length === 0 && rowsAffected(written) > 0) {
@@ -108,10 +108,10 @@ type Keyed = SQLiteTable & { id: SQLiteColumn };
/**
* Inserts one row and returns it as stored, including whatever the database
* filled in, defaults, an autoincrement id, a CURRENT_TIMESTAMP.
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP.
*
* This is the one case Postgres cannot shortcut either: without RETURNING there
* is no id to read back by. Hence the split is genuinely three-way, except
* is no id to read back by. Hence the split is genuinely three-way — except
* that sqlite and pg both have RETURNING, so it collapses to two again.
*
* On MySQL the key comes from one of two places:
@@ -161,7 +161,7 @@ export async function insertReturning<T extends Keyed>(
* back by an explicit condition.
*
* `user_preferences` is keyed by `userId` and has no `id` column at all, so
* there is no insertId to read back by, the caller has to say what identifies
* there is no insertId to read back by — the caller has to say what identifies
* the row it just wrote.
*/
export async function insertReturningWhere<T extends SQLiteTable>(
@@ -195,13 +195,13 @@ export async function insertReturningWhere<T extends SQLiteTable>(
*
* The clause has three spellings. SQLite and Postgres take
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
* names no columns**: it uses whichever unique key was violated. drizzle
* names no columns** — it uses whichever unique key was violated. drizzle
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
* calling it is a TypeError rather than a rejected query.
*
* The conflict target still has to be passed: it is what SQLite and Postgres
* need, and stating it keeps the caller honest about which unique constraint it
* is relying on, four of those were missing from the schema entirely until the
* is relying on — four of those were missing from the schema entirely until the
* cross-dialect tests went looking.
*/
export async function upsert<T extends SQLiteTable>(
@@ -1,7 +1,7 @@
/**
* Synchronous read-through cache for the settings table.
*
* 27 call sites read settings synchronously, during startup, inside request
* 27 call sites read settings synchronously — during startup, inside request
* handlers, and from the guacd server bootstrap. On SQLite that works because
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
* query at all, and making all 27 async would push `await` through code paths
@@ -35,7 +35,7 @@ export function primeSettingsCache(
/**
* Reads a cached setting.
*
* Returns null both for "not set" and "cache not primed yet", every caller
* Returns null both for "not set" and "cache not primed yet" — every caller
* already treats a missing setting as "use the default", and startup ordering
* means a read before priming should behave the same way rather than throw.
*/
@@ -141,8 +141,8 @@ export class UserRepository {
* at once would otherwise both see an empty table and both become admin.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright, "Transaction function
* cannot return a promise", so a single body cannot serve both. It fails
* rejects an async transaction callback outright — "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here.
*/
private async createCheckingIfFirst(
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
* /credential-sidebar/preferences:
* get:
* summary: Get the credential sidebar preferences for the current user
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here, credentials never had exploded preference columns on userPreferences, so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* tags:
* - Credential Sidebar
* responses:
@@ -0,0 +1,12 @@
const DONATION_MODAL_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
export function shouldShowDonationModal(
registeredAt: string,
donationModalDismissed: boolean,
now: number = Date.now(),
): boolean {
if (donationModalDismissed) return false;
const registeredAtMs = Date.parse(registeredAt);
if (Number.isNaN(registeredAtMs)) return false;
return now - registeredAtMs >= DONATION_MODAL_DELAY_MS;
}
@@ -23,7 +23,6 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -136,16 +135,6 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
@@ -828,7 +817,6 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -1034,7 +1022,6 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -80,14 +80,6 @@ export function containsOwnerPrivateAuthUpdate(
);
}
/**
* An update that names no auth type leaves the stored password and key
* alone. Clearing them would leave a password host with no password.
*/
export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
-3
View File
@@ -31,7 +31,6 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
@@ -922,8 +921,6 @@ router.put(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (keepsStoredSshAuth(effectiveAuthType)) {
// The update says nothing about authentication.
} else if (effectiveAuthType === "password") {
if (password) {
sshDataObj.password = password;
+8 -8
View File
@@ -9,14 +9,6 @@ const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
/**
* @openapi
* /open-tabs:
@@ -28,6 +20,14 @@ function getTabTtlMs(): number {
* 200:
* description: List of open tabs ordered by tab_order.
*/
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
+27 -48
View File
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
import { PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import {
pluginDocsUrl,
@@ -82,55 +82,37 @@ router.get("/public", async (_req: Request, res: Response) => {
try {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
type GuestManifest = {
contributes?: {
guest?: boolean;
guestViews?: string[];
tabs?: unknown[];
panels?: unknown[];
};
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const guests = records.flatMap((record) => {
const plugins = records.flatMap((record) => {
if (record.state !== "enabled") return [];
const loaded = loader.get(record.id);
let manifest: {
contributes?: { guest?: boolean; guestViews?: string[] };
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
try {
const manifest = JSON.parse(record.manifestJson) as GuestManifest;
return manifest?.contributes?.guest === true
? [{ record, manifest }]
: [];
manifest = JSON.parse(record.manifestJson);
} catch {
return [];
}
});
// Only guest plugins load on a guest page, so a dependency that is not
// one would block the plugin there for nothing.
const guestIds = new Set(guests.map(({ record }) => record.id));
const onlyGuests = (deps: Record<string, string> = {}) =>
Object.fromEntries(
Object.entries(deps).filter(([id]) => guestIds.has(id)),
);
const plugins = guests.map(({ record, manifest }) => {
const loaded = loader.get(record.id);
return {
id: record.id,
name: record.name,
// Not before login: it tells a scanner which known bugs apply.
version: "",
enabled: true,
state: loaded?.state ?? record.state,
// The tab and panel ids a guest page may register, which the
// browser checks against the manifest.
contributes: {
guest: true,
guestViews: manifest.contributes?.guestViews ?? [],
tabs: manifest.contributes?.tabs ?? [],
panels: manifest.contributes?.panels ?? [],
if (manifest?.contributes?.guest !== true) return [];
return [
{
id: record.id,
name: record.name,
// Not before login: it tells a scanner which known bugs apply.
version: "",
enabled: true,
state: loaded?.state ?? record.state,
contributes: {
guest: true,
guestViews: manifest.contributes.guestViews ?? [],
},
dependencies: manifest.dependencies ?? {},
optionalDependencies: manifest.optionalDependencies ?? {},
...describePluginFrontend(loaded),
},
dependencies: onlyGuests(manifest.dependencies),
optionalDependencies: onlyGuests(manifest.optionalDependencies),
...describePluginFrontend(loaded),
};
];
});
res.json(plugins);
} catch (error) {
@@ -2280,10 +2262,7 @@ router.post(
"/upload",
authenticateJWT,
requireManagePlugins,
express.raw({
type: "application/octet-stream",
limit: MAX_UPLOAD_BYTES,
}),
express.raw({ type: "application/octet-stream", limit: "50mb" }),
async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
-75
View File
@@ -75,74 +75,6 @@ async function canManageHostSharing(
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
/**
* Drops shares handed out by someone who can no longer manage sharing on the
* host, then repeats so shares they passed on further go too.
*/
export async function pruneHostGrants(hostId: number): Promise<number> {
const repository = createCurrentRbacAccessRepository();
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listHostAccess(hostId)) {
if (!grant.grantedBy || grant.grantedBy === ownerId) continue;
if ((await canManageHostSharing(grant.grantedBy, hostId)).allowed) {
continue;
}
await repository.revokeHostAccess(grant.id, hostId);
removed++;
changed = true;
}
}
return removed;
}
/** pruneHostGrants for credential shares. */
export async function pruneCredentialGrants(
credentialId: number,
): Promise<number> {
const repository = createCurrentCredentialAccessRepository();
const owner =
await createCurrentCredentialRepository().findById(credentialId);
if (!owner) return 0;
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listForCredential(credentialId)) {
if (!grant.grantedBy || grant.grantedBy === owner.userId) continue;
if (
(await canManageCredentialSharing(grant.grantedBy, credentialId))
.allowed
) {
continue;
}
await repository.revoke(grant.id, credentialId);
removed++;
changed = true;
}
}
return removed;
}
/** Prunes everything these users shared, after they lost a role. */
export async function pruneGrantsBy(userIds: string[]): Promise<void> {
for (const userId of userIds) {
permissionManager.invalidateUserPermissionCache(userId);
for (const hostId of await createCurrentRbacAccessRepository().listHostIdsGrantedBy(
userId,
)) {
await pruneHostGrants(hostId);
}
for (const credentialId of await createCurrentCredentialAccessRepository().listCredentialIdsGrantedBy(
userId,
)) {
await pruneCredentialGrants(credentialId);
}
}
}
export interface ShareTarget {
type: "user" | "role";
id: string | number;
@@ -368,7 +300,6 @@ router.post(
});
}
await pruneHostGrants(hostId);
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
@@ -849,7 +780,6 @@ router.patch(
hostId,
update,
);
await pruneHostGrants(hostId);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
@@ -933,7 +863,6 @@ router.delete(
accessId,
hostId,
);
await pruneHostGrants(hostId);
databaseLogger.info("Permission revoked", {
operation: "rbac_permission_revoke",
adminId: userId,
@@ -1454,7 +1383,6 @@ router.delete(
for (const userId of deletedUserIds) {
permissionManager.invalidateUserPermissionCache(userId);
}
await pruneGrantsBy(deletedUserIds);
res.json({
success: true,
@@ -1747,7 +1675,6 @@ router.delete(
}
permissionManager.invalidateUserPermissionCache(targetUserId);
await pruneGrantsBy([targetUserId]);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
adminId: req.userId!,
@@ -1961,7 +1888,6 @@ router.post(
success: true,
});
await pruneCredentialGrants(credentialId);
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
@@ -2045,7 +1971,6 @@ router.delete(
return res.status(404).json({ error: "Access grant not found" });
}
await repository.revoke(accessId, credentialId);
await pruneCredentialGrants(credentialId);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to revoke credential access", error, {
@@ -1,64 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import type { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { isCookieOriginAllowed } from "../../utils/ws-auth.js";
type UserSocketTicketRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
export function registerUserSocketTicketRoutes(
router: Router,
{ authenticateJWT, authManager }: UserSocketTicketRoutesDeps,
): void {
/**
* @openapi
* /users/ws-ticket:
* post:
* summary: Get a WebSocket ticket
* description: Returns a token that is valid for one minute and only opens plugin WebSockets. The web app sends it as a subprotocol, so sockets work behind a proxy that rewrites the Host header. Only a page on this server can get one.
* tags:
* - Users
* responses:
* 200:
* description: The ticket.
* content:
* application/json:
* schema:
* type: object
* properties:
* ticket:
* type: string
* 401:
* description: Not authenticated, or not a login session.
* 403:
* description: The request came from a page on another site.
* 500:
* description: Failed to issue the ticket.
*/
router.post("/ws-ticket", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
const sessionId = authReq.sessionId;
if (!sessionId || authReq.apiKeyId) {
return res.status(401).json({ error: "A login session is required" });
}
// The cookie rides along from sibling subdomains too, so the ticket only
// goes to a page the browser says is this server.
if (req.cookies?.jwt && !isCookieOriginAllowed(req)) {
return res.status(403).json({ error: "Not allowed from this origin" });
}
try {
const ticket = await authManager.issueSocketTicket(
authReq.userId,
sessionId,
);
res.setHeader("Cache-Control", "no-store");
res.json({ ticket });
} catch (err) {
authLogger.error("Failed to issue a socket ticket", err);
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
}
+49 -2
View File
@@ -20,13 +20,13 @@ import {
isNativeTokenExportRequest,
resolveDesktopAutoSessionUser,
} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
@@ -595,6 +595,11 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth = hasPassword && isExternalAccount(user);
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
!!user.donationModalDismissed,
);
res.json({
userId: user.id,
username: user.username,
@@ -605,6 +610,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} catch (err) {
@@ -613,6 +619,48 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
});
/**
* @openapi
* /users/me/dismiss-donation-modal:
* post:
* summary: Permanently dismiss the donation reminder modal
* description: Marks the donation reminder modal as dismissed for the currently authenticated user so it is never shown to them again.
* tags:
* - Users
* responses:
* 200:
* description: Donation modal dismissed.
* 401:
* description: Invalid userId or user not found.
* 500:
* description: Failed to dismiss donation modal.
*/
router.post(
"/me/dismiss-donation-modal",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(401).json({ error: "Invalid userId" });
}
try {
const updated = await createCurrentUserRepository().update(userId, {
donationModalDismissed: true,
});
if (!updated) {
return res.status(401).json({ error: "User not found" });
}
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to dismiss donation modal", err);
return res
.status(500)
.json({ error: "Failed to dismiss donation modal" });
}
},
);
/**
* @openapi
* /users/me/token:
@@ -1531,7 +1579,6 @@ registerUserExternalAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more