Compare commits

..
7 Commits
Author SHA1 Message Date
0149260926 v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 14:58:27 -05:00
LukeGus a075ff4808 chore: add publish sdk workflow to main 2026-10-09 16:21:15 -05:00
Luke Gustafson 2acd858ca4 Update copyright year in LICENSE file 2026-10-06 20:48:27 -05:00
LukeGus 064fb7ea3c chore: bump Homebrew cask to 2.9.2 2026-10-06 02:12:33 +00:00
Luke Gustafson 2b309e9582 release-2.9.2 (#1566)
* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows
2026-10-05 20:24:46 -05:00
LukeGus 40df00bd95 chore: point cli install to @termix-ssh/cli 2026-10-05 18:14:46 -05:00
LukeGus 6c342f3957 chore: sync Crowdin translations 2026-10-05 06:28:23 +00:00
341 changed files with 20945 additions and 2843 deletions

No files matched your search

-15
View File
@@ -1,21 +1,6 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"chore",
"revert"
]
],
"subject-case": [0]
}
}
+21 -44
View File
@@ -5,13 +5,22 @@ yarn-error.log*
dist
build
.next
.nuxt
coverage
release
.env.local
.env.development.local
.env.test.local
.env.production.local
# 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
.vscode
.idea
@@ -29,63 +38,31 @@ 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
.next
.nuxt
.vuepress/dist
.serverless
.fusebox/
.dynamodb/
.tern-port
*.tgz
.node_repl_history
-14
View File
@@ -1,14 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,cjs,mjs,jsx,ts,tsx,json,css,scss,md,yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+2 -19
View File
@@ -1,22 +1,5 @@
* text=auto eol=lf
*.js text eol=lf
*.cjs text eol=lf
*.mjs text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.sh text eol=lf
*.bash text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
@@ -25,10 +8,10 @@
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.svg binary
*.icns binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.icns binary
-1
View File
@@ -1 +0,0 @@
custom: https://donate.termix.site/
-32
View File
@@ -1,32 +0,0 @@
# Overview
_Short summary of what this PR does_
- [ ] Added: ...
- [ ] Updated: ...
- [ ] Removed: ...
- [ ] Fixed: ...
# Changes Made
_Detailed explanation of changes (if needed)_
- ...
# Related Issues
_Link any issues this PR addresses_
- Closes #ISSUE_NUMBER
- Related to #ISSUE_NUMBER
# Screenshots / Demos
_(Optional: add before/after screenshots, GIFs, or console output)_
# Checklist
- [ ] Code follows project style guidelines
- [ ] Supports mobile and desktop UI/app (if applicable)
- [ ] I have read [Contributing.md](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)
- [ ] This is not a translation request. See [docs](https://docs.termix.site/translations)
+13 -6
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 1.8.0)"
description: "Version to build (e.g., 26.10.0)"
required: true
build_type:
description: "Build type"
@@ -22,11 +22,11 @@ on:
workflow_call:
inputs:
version:
description: "Version to build (e.g., 1.8.0)"
description: "Version to build (e.g., 26.10.0)"
required: true
type: string
build_type:
description: "Build type (Development or Production)"
description: "Build type (Development, Beta or Production)"
required: true
type: string
dry_run:
@@ -52,7 +52,14 @@ jobs:
fetch-depth: 1
- name: Resolve source revision
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
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 }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -74,7 +81,7 @@ jobs:
ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "$VERSION" "latest")
TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION")
else
@@ -120,7 +127,7 @@ jobs:
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
org.opencontainers.image.created=${{ github.run_id }}
org.opencontainers.image.created=${{ env.BUILD_CREATED }}
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
- name: Login to Docker Hub as termixssh
+23 -7
View File
@@ -117,6 +117,11 @@ 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: |
@@ -220,6 +225,11 @@ 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 }}
@@ -429,6 +439,11 @@ 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: |
@@ -468,14 +483,15 @@ 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
if: steps.check_certs.outputs.has_certs == 'true'
id: mas_build
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
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"
@@ -534,7 +550,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
npm run build
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
@@ -555,7 +571,7 @@ jobs:
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
- name: Upload macOS MAS PKG
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')
if: steps.mas_build.outcome == 'success' && 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
@@ -722,7 +738,7 @@ jobs:
choco pack termix-ssh.nuspec
if ($LASTEXITCODE -ne 0) {
throw "Chocolatey push failed with exit code $LASTEXITCODE"
throw "Chocolatey pack failed with exit code $LASTEXITCODE"
}
- name: Check for Chocolatey API Key
@@ -952,8 +968,8 @@ jobs:
cp Casks/termix.rb 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
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
- name: Verify Cask syntax
run: |
+1 -1
View File
@@ -79,6 +79,6 @@ jobs:
" "$VERSION"
gh release create "sdk-v$VERSION" \
--target "${{ github.sha }}" \
--title "Plugin SDK $VERSION" \
--title "Plugin SDK v$VERSION" \
--notes-file sdk-notes.md \
--latest=false
+27 -67
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 }}"
crowdin:
sync-main:
needs: [prep, normalize]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
@@ -265,79 +265,27 @@ jobs:
fetch-depth: 0
token: ${{ secrets.TERMIX_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Merge main into dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git fetch origin main
git merge origin/main -X ours --no-edit || true
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
- name: Clean up stale Crowdin git integration branch
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
if [ -n "$PR_NUMBER" ]; then
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
# -X ours leaves modify/delete conflicts, so settle those to the dev side too
if ! git merge origin/main -X ours --no-edit; then
git diff --name-only --diff-filter=U | while read -r f; do
if git cat-file -e "HEAD:$f" 2>/dev/null; then
git checkout --ours -- "$f" && git add -- "$f"
else
git rm -q -- "$f"
fi
done
git commit --no-edit
fi
git push origin --delete i18n_translate || true
- name: Upload sources to Crowdin
uses: crowdin/github-action@v3
with:
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
# Off until the machine translation credits are topped up.
- name: Pre-translate untranslated strings
if: false
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
run: node scripts/crowdin-pretranslate.cjs
- name: Download translations from Crowdin
uses: crowdin/github-action@v3
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Commit translations to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git add src/ui/locales/translated
if git diff --cached --quiet; then
echo "No translation changes to commit."
exit 0
fi
git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
needs: [prep, 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') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
@@ -462,7 +410,7 @@ jobs:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
TAG="${{ needs.prep.outputs.release_tag }}"
TITLE="v${{ needs.prep.outputs.version }}"
TITLE="Termix v${{ needs.prep.outputs.version }}"
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
else
@@ -500,7 +448,7 @@ jobs:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
VERSION="${{ needs.prep.outputs.version }}"
TITLE="Termix ${VERSION%-beta.*} Beta ${VERSION##*-beta.}"
TITLE="Termix v${VERSION%-beta.*} Beta ${VERSION##*-beta.}"
TAG="${{ needs.prep.outputs.release_tag }}"
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
gh release edit "$TAG" --repo ${{ github.repository }} \
@@ -591,6 +539,18 @@ jobs:
gh release delete "$TAG" --repo ${{ github.repository }} --yes || true
done
# The docs site reads the env catalog, API spec and download list from the
# newest stable release.
docs-refresh:
needs: [prep, electron-release]
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Tell the docs site
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: gh api repos/Termix-SSH/Docs/dispatches -f event_type=core-released
cleanup:
needs: [prep, merge-to-main, electron-release, cask-commit-back]
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && (needs.prep.outputs.mode == 'Everything' || needs.prep.outputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }}
+3 -3
View File
@@ -20,10 +20,10 @@ coverage
termix-plugin-signing.key
/src/**/*.js
.vscode/*
!.vscode/settings.json
.idea
.vscode/
.idea/
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
+3 -13
View File
@@ -1,24 +1,14 @@
build
coverage
dist
node_modules
package-lock.json
dist-ssr
release
node_modules
src/mcp-server/node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
db
.env
*.min.js
*.min.css
openapi.json
# Generated by drizzle-kit; formatting is the tool's own
drizzle/
src/ui/settings/settings-search-index.ts
-22
View File
@@ -1,22 +0,0 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.prettierPath": "./node_modules/prettier",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"[markdown]": {
"files.trimTrailingWhitespace": false
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}
+21 -2
View File
@@ -4,12 +4,31 @@
### Added
- Temp
- 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
- 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
- Tem
- 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
### 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
+1 -1
View File
@@ -57,7 +57,7 @@ npm run format # format the code with Prettier
## Guidelines
- Follow the existing code style. The frontend uses React, Tailwind CSS and shadcn components in `src/ui/`.
- Never hardcode text. Use `t()` and add the English string to `src/ui/locales/en.json`. Other languages are translated on [Crowdin](https://docs.termix.site/translations).
- Never hardcode text. Use `t()` and add the English string to `src/ui/locales/en.json`. That's the only locale file you need to update. Other languages are translated automatically, and if an automatic translation is wrong you can fix it in that language's file.
- Keep the UI flat and square. Avoid rounded corners on panels, cards and dialogs.
- Use the theme variables (like `var(--bg-base)` and `var(--text-primary)`) instead of fixed colors.
- There is one component tree for desktop and mobile. Use `src/ui/hooks/use-mobile.ts` for layout changes.
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.9.1"
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
version "2.9.2"
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
-7
View File
@@ -1,7 +0,0 @@
{
"drips": {
"ethereum": {
"ownedBy": "0x67e0C779119D9BcC2187564A66B80a58767d05d1"
}
}
}
+200 -10
View File
@@ -1,13 +1,203 @@
Copyright 2025 Luke Gustafson
Copyright 2026 Luke Gustafson
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
http://www.apache.org/licenses/LICENSE-2.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11 -7
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).
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).
<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 · MSI Installer · Chocolatey</td>
<td>Portable · EXE and MSI Installer · Chocolatey</td>
</tr>
<tr>
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></td>
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
</tr>
<tr>
<td align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td>
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
<td>Apple App Store · DMG · Homebrew</td>
</tr>
<tr>
@@ -149,6 +149,10 @@ 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:
@@ -158,8 +162,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
ports:
- "4822:4822"
volumes:
- termix-data:/termix-data
networks:
- termix-net
@@ -254,4 +258,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
## License
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
-23
View File
@@ -1,23 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
## External secret storage
By default, a single-container installation generates its keys in the Termix
data directory for ease of recovery. Production deployments that keep backups
or database files outside a trusted encrypted volume should set
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
secret manager:
- `JWT_SECRET` (at least 64 characters)
- `DATABASE_KEY` (64 hexadecimal characters)
- `ENCRYPTION_KEY` (64 hexadecimal characters)
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
Hardened mode fails closed instead of writing a replacement key beside the
encrypted database.
+2 -2
View File
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"css": "src/ui/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@@ -15,7 +15,7 @@
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"ui": "@/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
-9
View File
@@ -1,9 +0,0 @@
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"
preserve_hierarchy: true
files:
# Core has always lived at /en.json in Crowdin; moving it would orphan its translations.
- source: /src/ui/locales/en.json
dest: /en.json
translation: /src/ui/locales/translated/%locale_with_underscore%.json
+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:26-slim AS deps
FROM --platform=$BUILDPLATFORM node:24-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:26-slim AS opkssh-downloader
FROM node:24-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:26-slim AS production-deps
FROM node:24-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:26-slim
FROM node:24-slim
WORKDIR /app
# The release the opkssh-downloader stage baked in, so the plugin trusts that
+60 -46
View File
@@ -4,152 +4,162 @@
"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": "1725735a058065b211436b166c0ef7d4ca6e74438102764664381160a2b305d3"
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ea121658305bed9d28e046e096fc458976258d7cb77387e1b8ac82ccb9a001cb"
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "6cb00cf9464e42dbc46786a6ef74b07d62da39ece9905197584d2934265b9b10"
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"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": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5",
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "4a774d4a3685b0ee8da5b73d14c60d40f10b6a3f1c5b31a5dfefb94ba30fb7ad"
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "403513bc1f96a7c21d39d55c8fd1d4e55fde7b8e6ec63228839bf3243d93e68a"
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
},
{
"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": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac",
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "2d8cb269242c12522a5195fa44ec14e95ade3b00d7e7be12e9204631518a0f88"
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
},
{
"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": "5c0a585dcffd1cd523e94d45f2eda32800c403a4c832854fec7331b651da7b7d"
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "7e8a17aa9053954e937ac2274928e29b821dfd671759e356551b27c13e23c9ad"
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "cb6149ab69e348e5a0a1939b31943cb64aded3e1ee930b91dc77c46a715990d0"
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
},
{
"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": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39",
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"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": "b6fbe38cd6d62d0b361c102f13f050ee8a281983dc806e32e8d8feeb416ceb20"
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
"onboarding": {
"recommended": "desktop"
"recommended": "desktop",
"consent": false
}
},
{
"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": "ff0e6948806e0127399d41a6abd0f41fce404e65ba2c9d8044fb6dced401f4bb"
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
},
{
"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": "4d2e363086a8fe79815bf76b4ed359ac82d5926373ab24854028e4dbc5a57ac6"
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"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": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714",
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
"sha256": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
@@ -157,19 +167,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": "997af2068f88c017c76a057683ac5e367566b5b8bc0961fc1e29c32b702a9059"
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "963a84859f8411bcb27e2950d46a7a43b239ace1295ac2abc4a77ecec43806d9"
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
"onboarding": {
"recommended": true,
"consent": true
@@ -179,72 +189,76 @@
"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": "00dfe3da65f78d7d085817c93a4e01bfef6931fb70ff5576c76e2f641e337555"
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
},
{
"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": "bbc508410296ca3434b5ba8c40fa5ef87646d104ec6f75da3bf0e22313bbbf18"
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "d84b0ef1a309bb3a89288f488a242dd19df9e46b237df778c2f2a77e77867960"
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
},
{
"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": "e680c22279438a566d1033939e4a3063a0fbbeb7f57b6db3bd6712bed09a4388"
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "c974be686488905277a52440b248d14370f3f543a7bd53f3485d5b6db4fab40b"
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
},
{
"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": "e93d8535617783bc538d415f25a5acc04e3805417002d68d4ebc4044c8fbed8c"
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
services:
termix:
image: ghcr.io/lukegus/termix:latest
image: ghcr.io/termix-ssh/termix:latest
container_name: termix
restart: unless-stopped
ports:
+19
View File
@@ -68,6 +68,25 @@ fi
mkdir -p /tmp/nginx
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
# Which proxies may set the client IP through X-Forwarded-For. Unset keeps
# the private ranges in the template.
if [ -n "$TRUSTED_PROXIES" ]; then
REAL_IP_LINES=""
for proxy in $(echo "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in
*[!0-9a-fA-F:./]*) echo "Ignoring invalid TRUSTED_PROXIES entry: $proxy" ;;
*) REAL_IP_LINES="${REAL_IP_LINES} set_real_ip_from ${proxy};\n" ;;
esac
done
echo "Trusting X-Forwarded-For from: $TRUSTED_PROXIES"
awk -v lines="$REAL_IP_LINES" '
/# BEGIN TRUSTED_PROXIES/ { printf "%s", lines; skip = 1; next }
/# END TRUSTED_PROXIES/ { skip = 0; next }
!skip
' /tmp/nginx/nginx.conf > /tmp/nginx/nginx.conf.tmp
mv /tmp/nginx/nginx.conf.tmp /tmp/nginx/nginx.conf
fi
if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then
echo "HTTP and HTTPS use port $SSL_PORT; disabling the HTTP redirect listener"
sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf
+2
View File
@@ -41,10 +41,12 @@ http {
image/svg+xml;
client_header_timeout 300s;
# BEGIN TRUSTED_PROXIES
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
# END TRUSTED_PROXIES
real_ip_header X-Forwarded-For;
real_ip_recursive on;
+2
View File
@@ -41,10 +41,12 @@ http {
image/svg+xml;
client_header_timeout 300s;
# BEGIN TRUSTED_PROXIES
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
# END TRUSTED_PROXIES
real_ip_header X-Forwarded-For;
real_ip_recursive on;
-1
View File
@@ -1 +0,0 @@
If you are an AI agent, do not place documentation files here. This is for maintainers only.
@@ -0,0 +1 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1791388888997,
"tag": "0069_plugin_channel",
"breakpoints": true
},
{
"idx": 70,
"version": "5",
"when": 1791550497926,
"tag": "0070_drop_donation_modal",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "users" DROP COLUMN "donation_modal_dismissed";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1791388888397,
"tag": "0069_plugin_channel",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1791550496661,
"tag": "0070_drop_donation_modal",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -456,6 +456,13 @@
"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": "10.15",
"minimumSystemVersion": "12.0",
"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")
+460
View File
@@ -0,0 +1,460 @@
{
"groups": {
"server": "Server",
"https": "HTTPS",
"database": "Database",
"secrets": "Secrets",
"auth": "Sign in",
"proxy": "Proxies and origins",
"logging": "Logs and audit",
"plugins": "Plugins",
"desktop": "Desktop app",
"internal": "Internal"
},
"vars": [
{
"name": "PORT",
"group": "server",
"default": "8080",
"description": "Port the web UI listens on in the Docker image."
},
{
"name": "DATA_DIR",
"group": "server",
"default": "/app/data",
"description": "Where Termix keeps its database, keys, plugins and uploads. Mount a volume here. Outside Docker the default is ./db/data."
},
{
"name": "ALLOW_EMPTY_DATA_DIR",
"group": "server",
"default": "false",
"description": "Start even when DATA_DIR is empty but an older data folder exists elsewhere. Only set this if you really want a fresh install."
},
{
"name": "PUID",
"group": "server",
"default": "1000",
"docker": true,
"description": "User id the container runs as. Match it to the owner of your data volume."
},
{
"name": "PGID",
"group": "server",
"default": "1000",
"docker": true,
"description": "Group id the container runs as."
},
{
"name": "BASE_PATH",
"group": "server",
"description": "Serve Termix under a sub path, like /termix. Set the same path in your reverse proxy."
},
{
"name": "NODE_ENV",
"group": "server",
"default": "production",
"description": "Set by the image. Leave it alone."
},
{
"name": "VERSION",
"group": "server",
"description": "Set by the image to the Termix version. Leave it alone."
},
{
"name": "SETTINGS_CACHE_REFRESH_SECONDS",
"group": "server",
"default": "30",
"description": "How often each server instance reloads admin settings from the database. 0 turns the refresh off."
},
{
"name": "SSH_AUTH_SOCK",
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
"default": "false",
"description": "Serve HTTPS. Termix makes a self-signed certificate on first boot unless you give it one."
},
{
"name": "SSL_PORT",
"group": "https",
"default": "8443",
"description": "Port for HTTPS."
},
{
"name": "SSL_CERT_PATH",
"group": "https",
"default": "/app/data/ssl/termix.crt",
"description": "Certificate file for HTTPS."
},
{
"name": "SSL_KEY_PATH",
"group": "https",
"default": "/app/data/ssl/termix.key",
"description": "Private key file for HTTPS."
},
{
"name": "SSL_DOMAIN",
"group": "https",
"default": "localhost",
"description": "Domain put in the self-signed certificate."
},
{
"name": "TERMIX_SSL_TERMINATED_BY_NGINX",
"group": "https",
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
"default": "sqlite",
"description": "sqlite, postgres or mysql."
},
{
"name": "DATABASE_URL",
"group": "database",
"description": "Connection URL for postgres or mysql, like postgres://user:pass@db:5432/termix."
},
{
"name": "DATABASE_POOL_MAX",
"group": "database",
"default": "10",
"description": "Most connections Termix opens to postgres or mysql."
},
{
"name": "DATABASE_SSL",
"group": "database",
"description": "TLS for postgres or mysql: require, no-verify or disable."
},
{
"name": "DB_FILE_ENCRYPTION",
"group": "database",
"default": "true",
"description": "Encrypt the SQLite file at rest. Set false only if something else already encrypts the disk."
},
{
"name": "DB_FILE_KEY",
"group": "database",
"secret": true,
"description": "Only used to open SQLite files encrypted by very old versions. You almost never need it."
},
{
"name": "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP",
"group": "database",
"default": "false",
"description": "Skip the backup Termix takes of the data folder before an upgrade changes the database."
},
{
"name": "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP",
"group": "database",
"default": "3",
"description": "How many of those upgrade backups to keep."
},
{
"name": "DRIZZLE_MIGRATIONS_DIR",
"group": "database",
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
"secret": true,
"description": "Signs sign-in sessions. At least 64 characters. Made for you and saved in DATA_DIR/.env if not set. Also read from JWT_SECRET_FILE."
},
{
"name": "DATABASE_KEY",
"group": "secrets",
"secret": true,
"description": "Key for the encrypted SQLite file, 64 hex characters. Made for you if not set. Also read from DATABASE_KEY_FILE."
},
{
"name": "ENCRYPTION_KEY",
"group": "secrets",
"secret": true,
"description": "Wraps the data keys that encrypt passwords and SSH keys, 64 hex characters. Made for you if not set. Also read from ENCRYPTION_KEY_FILE."
},
{
"name": "INTERNAL_AUTH_TOKEN",
"group": "secrets",
"secret": true,
"description": "Token the server uses to call itself. Made for you if not set. Also read from INTERNAL_AUTH_TOKEN_FILE."
},
{
"name": "TERMIX_REQUIRE_EXTERNAL_SECRETS",
"group": "secrets",
"default": "false",
"description": "Refuse to start unless the four secrets above come from env vars or _FILE files, so none are written to disk."
},
{
"name": "OIDC_SYSTEM_SECRET",
"group": "secrets",
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "WEBAUTHN_SYSTEM_SECRET",
"group": "secrets",
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
"description": "true or false. Overrides the admin setting for signing in with a username and password."
},
{
"name": "ALLOW_REGISTRATION",
"group": "auth",
"description": "true or false. Overrides the admin setting for creating new accounts."
},
{
"name": "ALLOW_PASSWORD_RESET",
"group": "auth",
"description": "true or false. Overrides the admin setting for resetting a password from the sign in page."
},
{
"name": "EXTERNAL_ALLOW_REGISTRATION",
"group": "auth",
"description": "true or false. Overrides the admin setting for creating accounts the first time someone signs in with SSO or LDAP."
},
{
"name": "EXTERNAL_FORCE_HTTPS",
"group": "auth",
"default": "false",
"description": "Build sign in callback URLs with https even when the request came in over http. Use it behind a proxy that does not send X-Forwarded-Proto."
},
{
"name": "OIDC_ALLOW_REGISTRATION",
"group": "auth",
"deprecated": "EXTERNAL_ALLOW_REGISTRATION",
"description": "Old name for EXTERNAL_ALLOW_REGISTRATION. Still read in 26.10."
},
{
"name": "OIDC_FORCE_HTTPS",
"group": "auth",
"deprecated": "EXTERNAL_FORCE_HTTPS",
"description": "Old name for EXTERNAL_FORCE_HTTPS. Still read in 26.10."
},
{
"name": "OIDC_CLIENT_ID",
"group": "auth",
"internal": true,
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
},
{
"name": "OIDC_ENV_OVERRIDE",
"group": "auth",
"internal": true,
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
},
{
"name": "TRUSTED_PROXY_AUTH_ENABLED",
"group": "auth",
"default": "false",
"description": "Let a proxy like Authelia or Authentik sign people in by sending their username in a header."
},
{
"name": "TRUSTED_PROXY_AUTH_TRUSTED_PROXIES",
"group": "auth",
"description": "Comma separated IPs or CIDRs of the proxies allowed to send the headers. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXY_AUTH_USERNAME_HEADER",
"group": "auth",
"default": "x-forwarded-username",
"description": "Header that holds the username."
},
{
"name": "TRUSTED_PROXY_AUTH_ROLE_HEADER",
"group": "auth",
"default": "x-forwarded-role",
"description": "Header that holds the user's groups or roles."
},
{
"name": "TRUSTED_PROXY_AUTH_ROLE_MAP",
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
"docker": true,
"description": "Comma separated IPs or CIDRs nginx takes the client IP from (X-Forwarded-For). Set it to your reverse proxy."
},
{
"name": "CORS_ALLOWED_ORIGINS",
"group": "proxy",
"description": "Comma separated extra origins allowed to call the API from a browser."
},
{
"name": "TERMIX_ALLOWED_ORIGINS",
"group": "proxy",
"description": "Comma separated extra origins allowed to open WebSockets."
},
{
"name": "HTTP_PROXY",
"group": "proxy",
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
},
{
"name": "HTTPS_PROXY",
"group": "proxy",
"description": "Proxy for outbound HTTPS requests."
},
{
"name": "NO_PROXY",
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
"default": "info",
"description": "debug, info, warn or error."
},
{
"name": "LOG_TIMESTAMP_FORMAT",
"group": "logging",
"description": "iso or 24h. Unset uses the local time format."
},
{
"name": "AUDIT_LOG_RETENTION_DAYS",
"group": "logging",
"description": "Delete audit entries older than this many days. Unset keeps them until the entry cap."
},
{
"name": "AUDIT_LOG_MAX_ENTRIES",
"group": "logging",
"default": "10000",
"description": "Most audit entries kept. The oldest go first."
},
{
"name": "AUDIT_LOG_FORWARD_URL",
"group": "logging",
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
},
{
"name": "AUDIT_LOG_FORWARD_TOKEN",
"group": "logging",
"secret": true,
"description": "Bearer token sent with forwarded audit entries."
},
{
"name": "SESSION_RECORDING_RETENTION_DAYS",
"group": "logging",
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
"description": "Use another plugin index instead of the official registry."
},
{
"name": "TERMIX_PLUGIN_STATS_URL",
"group": "plugins",
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
},
{
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins",
"default": "false",
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
"description": "Where the plugins shipped with Termix are read from. Set by the image."
},
{
"name": "TERMIX_PLUGIN_PUBLIC_RATE_LIMIT",
"group": "plugins",
"default": "120",
"description": "Requests per minute one IP may make to a plugin's public routes."
},
{
"name": "PLUGIN_MAX_KV_KEYS",
"group": "plugins",
"default": "10000",
"description": "Most key value entries one plugin may store."
},
{
"name": "TERMIX_DEV_RELOAD",
"group": "plugins",
"internal": true,
"description": "Set by npm run dev so plugin rebuilds reload without a restart."
},
{
"name": "TERMIX_DEV_RUNNER",
"group": "plugins",
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
"default": "false",
"description": "Desktop app only. Skip TLS certificate checks for the linked server. Only for testing."
},
{
"name": "ELECTRON_DISABLE_GPU",
"group": "desktop",
"description": "Desktop app only. Set to 1 to turn off GPU acceleration if the window is blank or flickers."
},
{
"name": "TERMIX_LOCAL_SHELL",
"group": "desktop",
"description": "Desktop app only. Shell the local terminal opens, like /bin/zsh or pwsh.exe."
},
{
"name": "SHELL",
"group": "desktop",
"internal": true,
"description": "Your login shell, used by the local terminal when TERMIX_LOCAL_SHELL is not set."
},
{
"name": "ELECTRON_EMBEDDED",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to its data folder."
},
{
"name": "XDG_CURRENT_DESKTOP",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store and window behavior."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
"internal": true,
"description": "Base path baked in at build time."
}
]
}
+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,35 +20,6 @@
<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;
-81
View File
@@ -1,81 +0,0 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"workspaces": {
".": {
"entry": [
"src/main.tsx",
"src/backend/starter.ts",
"src/backend/utils/swagger.ts",
"src/ui/plugin-host/sdk-ui.ts",
"src/ui/plugin-host/testing-host.tsx",
"src/**/tests/**/*.{ts,tsx}",
"scripts/**/*.{cjs,mjs}",
"electron/main.cjs",
"electron/preload.js",
"vitest.config.ts",
"vitest.setup.ts",
"vite.config.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"electron/**/*.cjs",
"scripts/**/*.{cjs,mjs}"
],
"ignoreBinaries": ["powershell"],
"ignoreDependencies": [
"@codemirror/theme-one-dark",
"@codemirror/view",
"@electron/notarize",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-slider",
"@radix-ui/react-slot",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@types/better-sqlite3",
"@types/cookie-parser",
"@types/cors",
"@types/express",
"@types/jsonwebtoken",
"@types/multer",
"@types/ssh2",
"@uiw/react-codemirror",
"@xterm/addon-clipboard",
"axios",
"bcryptjs",
"better-sqlite3",
"body-parser",
"chalk",
"class-variance-authority",
"clsx",
"cmdk",
"cookie-parser",
"cors",
"drizzle-orm",
"express",
"jsonwebtoken",
"multer",
"nanoid",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
"radix-ui",
"socks",
"ssh2",
"undici",
"husky",
"sharp",
"sonner",
"tailwind-merge"
]
},
"packages/plugin-sdk": {
"entry": ["src/*.ts", "src/testing/*.ts", "cli/**/*.mjs"],
"project": ["src/**/*.ts", "cli/**/*.mjs"]
}
}
}
+15 -36
View File
@@ -12,6 +12,7 @@
"packages/*"
],
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -142,7 +143,6 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
@@ -159,7 +159,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -169,14 +168,12 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "14.0.1",
@@ -1093,7 +1090,6 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz",
"integrity": "sha512-KwySDId/J0C/dlMGd77H3IN3nL7/HYVTerFp6DBhRnIY7RsbGGz30DBIyAhzhLDdqStW4wQHMP+/3qX8BQ1wTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
@@ -2702,7 +2698,6 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -2882,7 +2877,6 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -6236,7 +6230,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -7001,7 +6994,6 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -7018,7 +7010,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
@@ -7218,7 +7209,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/argue-cli": {
@@ -7391,7 +7381,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
@@ -7506,7 +7495,6 @@
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -7748,7 +7736,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/callsites": {
@@ -7974,7 +7961,6 @@
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
@@ -8272,7 +8258,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -9578,7 +9563,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -9606,7 +9590,6 @@
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
"type": "github",
@@ -9775,7 +9758,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -10038,7 +10020,6 @@
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
"integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
@@ -10584,7 +10565,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -10643,7 +10623,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
@@ -10700,7 +10679,6 @@
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -10799,7 +10777,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -11490,7 +11467,6 @@
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
@@ -12004,7 +11980,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
@@ -12075,7 +12050,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12085,7 +12059,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
@@ -12102,7 +12075,6 @@
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -12905,7 +12877,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13236,7 +13207,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -13249,7 +13219,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13338,7 +13307,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -14805,7 +14773,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -14977,7 +14944,6 @@
"version": "2.0.0-4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz",
"integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10"
@@ -15049,9 +15015,10 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.2",
"version": "1.0.5",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
@@ -15087,9 +15054,21 @@
"drizzle-orm": {
"optional": true
},
"i18next": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
},
"ssh2": {
"optional": true
},
+4 -6
View File
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
@@ -32,7 +32,7 @@
"dev:frontend": "vite",
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never",
@@ -47,6 +47,7 @@
"plugins:pin": "node scripts/pin-bundled-plugins.cjs"
},
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -166,10 +167,7 @@
"eslint --fix",
"prettier --write"
],
"*.{js,jsx}": [
"prettier --write"
],
"*.{json,css,md}": [
"*.{js,jsx,mjs,cjs,json,css,md}": [
"prettier --write"
]
},
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 1.0.5
### Fixed
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
## 1.0.4
### Fixed
- `termix-plugin build` puts a plugin's own Tailwind classes in core's utilities layer, so responsive, hover and group variants and `className` overrides on SDK components work. Plain classes core already ships stay below core's utilities as before, while variants core ships go with the plugin's own so `max-h-56 md:max-h-none` keeps its order. Rebuild a plugin to pick it up
## 1.0.3
Plugin API 1.2.0.
### Added
- `docs` manifest field: an https link to the plugin's docs. Termix shows it as a Documentation link on the plugin's page and in its settings
- `env` manifest field: the environment variables the plugin reads, listed in its docs and on its page
- `app.docs` and `useDocsUrl()` for links into the plugin's own docs, and `DocsLink` in `@termix-ssh/plugin-sdk/ui`
- `PanelShell` takes a `docs` link and shows it as a book icon in the header
- `@termix-ssh/plugin-sdk/openapi` with `buildOpenApi()`, and `termix-plugin openapi`, which writes `dist/openapi.json` from the `@openapi` blocks in `src/backend`. `build` runs it too
- `@termix-ssh/plugin-sdk/docs` with `pluginDocsUrl()`, `pluginDocsPage()` and `pluginEnvVars()`
- `termix-plugin validate` warns when `docs/index.md` is missing or the source reads an env var the manifest does not list
## 1.0.2
### Added
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+4 -2
View File
@@ -12,7 +12,7 @@
## Overview
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template). The full guide is at [docs.termix.site/develop](https://docs.termix.site/develop).
<br />
@@ -28,8 +28,10 @@ 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
npx termix-plugin migrations # write migrations from the table definitions
npx termix-plugin pack # write <id>-<version>.tmxplug
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
@@ -39,7 +41,7 @@ npx termix-plugin keygen --out <dir> # make a new signing key pair
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
```
`dev` needs developer mode on (Admin settings, Plugins) and an API key from an
`dev` needs developer mode on (Settings, General) and an API key from an
admin account. It installs the plugin from a file, marked unverified, and
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
in place of the flags. Reload the page to pick up frontend changes.
+47 -22
View File
@@ -6,6 +6,8 @@ import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
import { splitByCoreClasses } from "../lib/css-classes.mjs";
const BACKEND_ENTRIES = [
"src/backend/index.ts",
@@ -96,14 +98,36 @@ export function applyPatches(cwd, pluginId) {
}
}
const THEME_CSS = path.join(
const HOST_DIR = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"dist",
"host",
"theme.css",
);
const THEME_CSS = path.join(HOST_DIR, "theme.css");
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
/**
* Moves a plugin's Tailwind layers under their own names. Core orders
* termix-plugin-utilities above its base styles and below its own utilities,
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
* because the plugin's file loaded later. Only classes core also ships go
* there; `utilities: false` keeps the rest in core's own utilities layer.
*/
export function lowerPluginLayers(css, { utilities = true } = {}) {
const layers = utilities ? "properties|utilities" : "properties";
return css.replace(
new RegExp(`@layer (${layers})(?=[\\s{;,])`, "g"),
"@layer termix-plugin-$1",
);
}
/** Classes core's CSS has rules for, or null with an SDK built before 1.0.4. */
function readCoreClasses() {
if (!fs.existsSync(CORE_CLASSES)) return null;
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
}
/**
* Compiles the Tailwind classes the plugin's frontend uses into
@@ -112,19 +136,6 @@ const THEME_CSS = path.join(
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
/**
* Moves a plugin's Tailwind layers under their own names. Core orders
* termix-plugin-utilities above its base styles and below its own utilities,
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
* because the plugin's file loaded later.
*/
export function lowerPluginLayers(css) {
return css.replace(
/@layer (properties|utilities)(?=[\s{;,])/g,
"@layer termix-plugin-$1",
);
}
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
@@ -136,10 +147,9 @@ export async function buildTailwind(cwd, outDir) {
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
'@import "tailwindcss/utilities.css" layer(utilities);',
].join("\n");
const compiler = await compile(input, {
base: path.dirname(THEME_CSS),
onDependency: () => {},
});
// build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
const scanner = new Scanner({
sources: [
{
@@ -151,9 +161,23 @@ export async function buildTailwind(cwd, outDir) {
});
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const css = lowerPluginLayers(
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
);
const emit = async (candidates, options) =>
candidates.length === 0
? ""
: lowerPluginLayers(
optimize((await compiler()).build(candidates), {
minify: true,
}).code.trim(),
options,
);
const candidates = scanner.scan();
const coreClasses = readCoreClasses();
const { shared, own } = coreClasses
? splitByCoreClasses(candidates, coreClasses)
: { shared: candidates, own: [] };
const css = [await emit(shared), await emit(own, { utilities: false })]
.filter(Boolean)
.join("\n");
if (!css) return;
const target = path.join(outDir, "frontend.css");
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
@@ -241,6 +265,7 @@ export async function build({ cwd }) {
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
await openapi({ cwd, quiet: true });
console.log(`built ${pluginId}`);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDevOptions(args, env = process.env) {
const HINTS = {
DEVELOPER_MODE_OFF:
"Turn on developer mode in Admin settings, under Plugins, then save again.",
"Turn on Plugin developer mode in Settings, General, then save again.",
SIGNED_ONLY:
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
@@ -17,7 +17,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
external: BACKEND_EXTERNALS,
});
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
const module = await import(pathToFileURL(outfile).href);
const tables = module.tables ?? module.default;
if (!Array.isArray(tables)) {
throw new Error(
@@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { loadSdkModule } from "./validate.mjs";
/**
* Writes dist/openapi.json from the @openapi blocks in src/backend. A plugin
* with no documented routes gets no file.
*/
export async function openapi({ cwd, quiet = false }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const backendDir = path.join(cwd, "src", "backend");
if (!fs.existsSync(backendDir)) return null;
const { buildOpenApi, hasOpenApiPaths } = await loadSdkModule("openapi");
const spec = await buildOpenApi({
title: `${manifest.name ?? pluginId} API`,
version: manifest.version ?? "0.0.0",
description: manifest.description,
files: [path.join(backendDir, "**", "*.{ts,tsx,js,mjs}")],
});
const target = path.join(cwd, "dist", "openapi.json");
if (!hasOpenApiPaths(spec)) {
fs.rmSync(target, { force: true });
if (!quiet) console.log(`${pluginId}: no documented routes`);
return null;
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify(spec, null, 2)}\n`);
if (!quiet) {
console.log(
`wrote dist/openapi.json (${Object.keys(spec.paths).length} paths)`,
);
}
return target;
}
@@ -41,6 +41,10 @@ export async function validate({ cwd }) {
problems.push(...validatePackage(cwd, raw));
problems.push(...(await validateChangelogFile(cwd, raw)));
for (const warning of docsWarnings(cwd, raw)) {
console.warn(` warn ${warning}`);
}
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
@@ -49,6 +53,69 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
"NODE_ENV",
"PORT",
"BASE_PATH",
"VERSION",
"HOME",
"TMPDIR",
"TEMP",
"TMP",
"PATH",
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"ELECTRON_EMBEDDED",
]);
const ENV_READS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
];
function walkSources(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkSources(full, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
}
return out;
}
/**
* Docs gaps that do not block a release: no docs/index.md, or a process.env
* read the manifest does not list in env.
*/
export function docsWarnings(cwd, raw) {
const warnings = [];
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
warnings.push("docs/index.md is missing, so the plugin has no docs page");
}
const declared = new Set(
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
);
const missing = new Set();
for (const file of walkSources(path.join(cwd, "src"))) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of ENV_READS) {
for (const match of text.matchAll(pattern)) {
const name = match[1];
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
}
}
}
for (const name of [...missing].sort()) {
warnings.push(`reads ${name} but manifest env does not list it`);
}
return warnings;
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
@@ -151,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)),
);
for (const dialect of DIALECTS) {
if (present.length > 0 && !present.includes(dialect)) {
problems.push(`migrations/${dialect} is missing`);
}
}
const byDialect = new Map();
for (const dialect of present) {
+3
View File
@@ -15,6 +15,7 @@ import { sign, verify, keygen } from "./commands/sign.mjs";
import { migrations } from "./commands/migrations.mjs";
import { dev } from "./commands/dev.mjs";
import { changelog } from "./commands/changelog.mjs";
import { openapi } from "./commands/openapi.mjs";
const COMMANDS = {
build,
@@ -32,6 +33,7 @@ const COMMANDS = {
migrations,
dev,
changelog,
openapi,
};
const [command, ...args] = process.argv.slice(2);
@@ -50,6 +52,7 @@ if (!command || command === "--help" || command === "-h") {
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
" keygen Write a new signing key pair [--out dir]",
" migrations Generate migrations from the plugin's table definitions",
" openapi Write dist/openapi.json from the @openapi blocks in src/backend",
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
" dev Build, pack and install on a running server, again on every change",
" [--server url] [--key tmx_...] [--once]",
@@ -0,0 +1,37 @@
/**
* The class names a stylesheet has rules for, unescaped, so `.md\:w-44`
* reads as the candidate `md:w-44` a plugin writes.
*/
export function classNamesInCss(css) {
const names = new Set();
// Declarations hold numbers like 0.25rem, so only selectors are read.
const selectors = css.replace(/\{[^{}]*\}/g, "{}");
for (const match of selectors.matchAll(
/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)/g,
)) {
names.add(
match[1].replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_all, hex, char) =>
hex ? String.fromCodePoint(parseInt(hex, 16)) : char,
),
);
}
return names;
}
/**
* Splits a plugin's candidates by whether core's own CSS already has them.
* A plain class core ships keeps the plugin's copy below core's utilities, so
* it can never reorder core's rules. Everything else goes in core's utilities
* layer: plugin-only classes, and every variant (md:, hover:, ...) even when
* core ships it, so `max-h-56 md:max-h-none` is ordered in one build and a
* variant still beats core's own base class.
*/
export function splitByCoreClasses(candidates, coreClasses) {
const shared = [];
const own = [];
for (const candidate of candidates) {
const plain = !candidate.includes(":");
(plain && coreClasses.has(candidate) ? shared : own).push(candidate);
}
return { shared, own };
}
+22 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.2",
"version": "1.0.5",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -53,6 +53,14 @@
"types": "./dist/changelog.d.ts",
"default": "./dist/changelog.js"
},
"./docs": {
"types": "./dist/docs.d.ts",
"default": "./dist/docs.js"
},
"./openapi": {
"types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
@@ -102,6 +110,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
@@ -139,6 +148,18 @@
},
"@testing-library/react": {
"optional": true
},
"i18next": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
}
},
"publishConfig": {
@@ -85,6 +85,27 @@
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
"description": "Short lines listing what the plugin does, shown on its page."
},
"docs": {
"type": "string",
"pattern": "^https://",
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
},
"env": {
"type": "array",
"description": "Environment variables the plugin reads, listed in its docs.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
"default": { "type": "string" },
"required": { "type": "boolean" },
"secret": { "type": "boolean" }
}
}
},
"engine": {
"type": "object",
"additionalProperties": false,
+13 -2
View File
@@ -194,7 +194,9 @@ 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.
* be that version, so a release never ships without its notes. A beta like
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
* to be 1.1.0 or older.
*/
export function validateChangelog(
markdown: string,
@@ -206,7 +208,16 @@ export function validateChangelog(
return problems;
}
const newest = changelog.releases[0].version;
if (manifestVersion && newest !== manifestVersion) {
const base = manifestVersion
? semver.valid(semver.coerce(manifestVersion))
: null;
const betaOk =
!!manifestVersion &&
!!semver.prerelease(manifestVersion) &&
!!base &&
!!semver.valid(newest) &&
semver.lte(newest, base);
if (manifestVersion && newest !== manifestVersion && !betaOk) {
problems.push(
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
);
+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. Never indexable: MySQL cannot index TEXT. */
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
export const text = () => builder({ type: "text" });
/** Bounded string. Indexable on every engine. */
+3 -1
View File
@@ -80,11 +80,13 @@ function columnType(
// though it was declared unbounded. defineTable refuses to index a
// text/json column, so in practice this only catches a primary key.
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
return "text";
}
}
/** Column types that are TEXT on MySQL when nothing keys them. */
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
"text",
"json",
+60
View File
@@ -0,0 +1,60 @@
/**
* Docs links and env var entries from a manifest. Kept apart from manifest.ts
* so the browser can use them without pulling in semver.
*/
/**
* A docs link, or null for anything that is not an https URL. Core only ever
* opens it in a new tab, but a javascript: or file: link should never get there.
*/
export function pluginDocsUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value.trim());
if (url.protocol !== "https:") return null;
return url.toString().replace(/\/$/, "");
} catch {
return null;
}
}
/** Joins a page and anchor onto a plugin's docs URL. */
export function pluginDocsPage(
base: string,
page?: string,
anchor?: string,
): string {
const clean = (page ?? "").replace(/^\/+|\/+$/g, "");
const url = clean ? `${base}/${clean}` : base;
return anchor ? `${url}#${anchor.replace(/^#/, "")}` : url;
}
export const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
export const MAX_ENV_DESCRIPTION_LENGTH = 300;
/**
* An environment variable a plugin reads. Only shown in docs and on the
* plugin's page, so people know what they can set.
*/
export interface PluginEnvVar {
name: string;
description: string;
/** The value used when it is not set, as written in a compose file. */
default?: string;
required?: boolean;
/** Holds a password or key. */
secret?: boolean;
}
/** The usable env entries of a manifest. Bad ones are dropped. */
export function pluginEnvVars(value: unknown): PluginEnvVar[] {
if (!Array.isArray(value)) return [];
return value.filter(
(v): v is PluginEnvVar =>
typeof v === "object" &&
v !== null &&
typeof v.name === "string" &&
ENV_NAME_PATTERN.test(v.name) &&
typeof v.description === "string",
);
}
+26
View File
@@ -888,6 +888,16 @@ export interface DesktopApi {
onRemoteServerChange: (listener: () => void) => Disposer;
}
/** Links into this plugin's docs, from the manifest's docs field. */
export interface DocsApi {
/** The docs link, or null when the manifest has none. */
readonly url: string | null;
/** A page of the docs ("setup"), with an optional anchor. */
page: (page?: string, anchor?: string) => string | null;
/** Opens a page in a new tab. Does nothing without a docs link. */
open: (page?: string, anchor?: string) => void;
}
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -1017,6 +1027,8 @@ export interface TermixApp extends TermixAppInfo {
confirm: (options: ConfirmRequest) => Promise<boolean>;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/** This plugin's docs. Needs plugin API 1.2. */
docs: DocsApi;
/**
* Fires after this plugin's settings are saved from the settings screen or
* the host editor. Disposed automatically.
@@ -1091,6 +1103,11 @@ export interface SshAuthTypeInfo {
*/
export interface PluginHostBridge {
usePluginId: () => string;
useDocsUrl: (
pluginId: string,
page?: string,
anchor?: string,
) => string | null;
useTranslation: (pluginId: string) => {
t: TranslateFn;
language: string;
@@ -1290,6 +1307,15 @@ export function usePluginId(): string {
return requireHost().usePluginId();
}
/**
* A page of this plugin's docs, or null when the manifest has no docs link.
* Pass it to PanelShell's docs prop or use DocsLink. Needs plugin API 1.2.
*/
export function useDocsUrl(page?: string, anchor?: string): string | null {
const bridge = requireHost();
return bridge.useDocsUrl(bridge.usePluginId(), page, anchor);
}
/**
* Translation bound to this plugin's namespace. Keys the plugin does not
* define fall back to core's shared strings (common.*, hosts.*).
+1 -1
View File
@@ -32,7 +32,7 @@ export function execCommand(
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
reject(new Error(`Command timeout after ${timeoutMs}ms`));
}
}, timeoutMs);
+76 -1
View File
@@ -12,6 +12,21 @@
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
import {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
pluginDocsUrl,
type PluginEnvVar,
} from "./docs.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
pluginDocsPage,
pluginDocsUrl,
pluginEnvVars,
type PluginEnvVar,
} from "./docs.js";
/**
* The plugin API this build implements. A minor bump adds to the API and
@@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js";
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.1.0";
export const PLUGIN_API_VERSION = "1.2.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -601,6 +616,10 @@ export interface PluginManifest {
video?: string;
/** Short lines listing what the plugin does, shown on its page. */
features?: string[];
/** Where the plugin's docs live. https only. */
docs?: string;
/** Environment variables the plugin reads. */
env?: PluginEnvVar[];
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
@@ -654,6 +673,8 @@ const ALLOWED_TOP_LEVEL = new Set([
"icon",
"video",
"features",
"docs",
"env",
"dependencies",
"optionalDependencies",
"provides",
@@ -816,6 +837,12 @@ export function validateManifest(manifest: unknown): string[] {
);
}
validateFeatures(m.features, errors);
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
errors.push(
`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`,
);
}
validateEnv(m.env, errors);
for (const field of ["backend", "frontend", "locales"] as const) {
if (field in m && requireString(m[field], field, errors)) {
requireRelativePath(m[field] as string, field, errors);
@@ -873,6 +900,54 @@ function validateFeatures(features: unknown, errors: string[]): void {
});
}
function validateEnv(env: unknown, errors: string[]): void {
if (env === undefined) return;
if (!Array.isArray(env)) {
errors.push('Field "env" must be an array');
return;
}
const seen = new Set<string>();
env.forEach((entry, i) => {
const where = `env[${i}]`;
if (!isPlainObject(entry)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
entry,
["name", "description", "default", "required", "secret"],
where,
errors,
);
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
errors.push(
`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`,
);
} else if (seen.has(entry.name)) {
errors.push(`Field "${where}.name" repeats ${entry.name}`);
} else {
seen.add(entry.name);
}
if (
typeof entry.description !== "string" ||
entry.description.trim().length === 0 ||
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH
) {
errors.push(
`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`,
);
}
if ("default" in entry && typeof entry.default !== "string") {
errors.push(`Field "${where}.default" must be a string`);
}
for (const flag of ["required", "secret"] as const) {
if (flag in entry && typeof entry[flag] !== "boolean") {
errors.push(`Field "${where}.${flag}" must be true or false`);
}
}
});
}
function validateAuthor(author: unknown, errors: string[]): void {
if (author === undefined) return;
if (!isPlainObject(author)) {
+87
View File
@@ -0,0 +1,87 @@
/**
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
* every plugin use this, so the docs site renders them all the same way.
*/
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
export interface OpenApiTag {
name: string;
description?: string;
}
export interface BuildOpenApiOptions {
title: string;
version: string;
description?: string;
/** Source files or globs holding @openapi blocks. */
files: string[];
tags?: OpenApiTag[];
}
export type OpenApiSpec = Record<string, unknown> & {
paths?: Record<string, unknown>;
};
/** How every Termix route is signed in to. */
export const TERMIX_SECURITY_SCHEMES = {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"Session token sent in the Authorization header as Bearer and the token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "jwt",
description:
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
},
apiKeyAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key starting with tmx_",
description:
"API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
},
} as const;
export async function buildOpenApi(
options: BuildOpenApiOptions,
): Promise<OpenApiSpec> {
const spec = (await swaggerJSDoc({
definition: {
openapi: "3.0.3",
info: {
title: options.title,
version: options.version,
...(options.description ? { description: options.description } : {}),
},
servers: [{ url: "/", description: "Your Termix server" }],
components: {
securitySchemes: TERMIX_SECURITY_SCHEMES,
schemas: {
Error: {
type: "object",
properties: {
error: { type: "string" },
details: { type: "string" },
},
},
},
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
...(options.tags?.length ? { tags: options.tags } : {}),
},
apis: options.files.map((f) => f.replace(/\\/g, "/")),
failOnErrors: true,
} as Parameters<typeof swaggerJSDoc>[0])) as OpenApiSpec;
return spec;
}
/** Whether a spec documents any route. */
export function hasOpenApiPaths(spec: OpenApiSpec): boolean {
return Object.keys(spec.paths ?? {}).length > 0;
}
+1
View File
@@ -85,6 +85,7 @@ const offline = () =>
Promise.reject(new Error("No plugin API in a unit test; mock it"));
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
usePluginId: () => "test-plugin",
useDocsUrl: () => null,
useTranslation: () => ({
t: (key: string) => key,
language: "en",
+4 -2
View File
@@ -58,6 +58,8 @@ DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttribu
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
DocsLinkProps (type): DocsLinkProps
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
@@ -93,7 +95,7 @@ HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
@@ -128,7 +130,7 @@ PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, o
PanelDensity (type): PanelDensity
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
PanelViewMode (type): PanelViewMode
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

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

After

Width:  |  Height:  |  Size: 528 B

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

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

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

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+6 -3
View File
@@ -8,11 +8,12 @@
*
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
* changed since its last build is rebuilt first, then any built one (has
* changed since its last build is rebuilt first with this checkout's SDK, so
* SDK edits apply without publishing, then any built one (has
* dist/) is copied in place of its pin, or added when it has no pin.
*/
const { execSync } = require("node:child_process");
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const {
@@ -21,6 +22,7 @@ const {
extractArtifact,
findLocalPluginBuilds,
rebuildLocalPlugins,
localSdkBuildCommand,
copyLocalBuild,
buildBundledIndex,
} = require("./lib/bundled-plugins.cjs");
@@ -34,9 +36,10 @@ async function main() {
const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
const localDir = localArg ? path.resolve(root, localArg) : null;
const [node, buildArgs] = localSdkBuildCommand();
rebuildLocalPlugins(localDir, null, (repo, id) => {
console.log(`building ${id}`);
execSync("npm run build", { cwd: repo, stdio: "inherit" });
execFileSync(node, buildArgs, { cwd: repo, stdio: "inherit" });
});
const local = findLocalPluginBuilds(localDir);
+30
View File
@@ -3,6 +3,7 @@
*
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for
*
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
@@ -18,6 +19,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import * as esbuild from "esbuild";
import { compile, optimize } from "@tailwindcss/node";
import { Scanner } from "@tailwindcss/oxide";
import { classNamesInCss } from "../packages/plugin-sdk/cli/lib/css-classes.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
@@ -137,4 +141,30 @@ fs.copyFileSync(
path.join(outDir, "theme.css"),
);
// Every class core's own CSS has a rule for, found the way the Vite build
// finds them, so termix-plugin build knows which plugin classes it shares.
const indexCss = path.join(root, "src/ui/index.css");
const compiler = await compile(fs.readFileSync(indexCss, "utf8"), {
base: path.dirname(indexCss),
onDependency: () => {},
});
const rootSource =
compiler.root === "none"
? []
: compiler.root === null
? [{ base: root, pattern: "**/*", negated: false }]
: [{ ...compiler.root, negated: false }];
const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources],
});
const coreClasses = [
...classNamesInCss(
optimize(compiler.build(scanner.scan()), { minify: true }).code,
),
].sort();
fs.writeFileSync(
path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`,
);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
+35
View File
@@ -13,6 +13,7 @@ import {
buildBundledIndex,
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
} from "./lib/bundled-plugins.cjs";
@@ -354,6 +355,40 @@ describe("rebuilding local plugins", () => {
expect(isLocalBuildStale(root)).toBe(true);
});
it("is stale when the SDK CLI changed after the build", () => {
const root = repo(tempDir(), "Plugin-A", "a");
const cli = path.join(tempDir(), "cli");
fs.mkdirSync(cli);
fs.writeFileSync(path.join(cli, "index.mjs"), "x");
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x");
touch(path.join(root, "manifest.json"), 60);
touch(path.join(root, "src", "index.ts"), 60);
touch(path.join(cli, "index.mjs"), 60);
expect(isLocalBuildStale(root, cli)).toBe(false);
touch(path.join(root, "dist", "frontend.js"), 30);
touch(path.join(cli, "index.mjs"), 0);
expect(isLocalBuildStale(root, cli)).toBe(true);
expect(isLocalBuildStale(root)).toBe(false);
});
it("builds with this checkout's SDK CLI, not the plugin's own", () => {
const [command, args] = localSdkBuildCommand();
expect(command).toBe(process.execPath);
expect(args).toEqual([
path.resolve(
__dirname,
"..",
"packages",
"plugin-sdk",
"cli",
"index.mjs",
),
"build",
]);
});
it("builds only stale repos for bundled ids that have node_modules", () => {
const dir = tempDir();
repo(dir, "Plugin-A", "a");
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Fails when core reads an env var that env.catalog.json does not list. The
// docs site builds its env var page from that file.
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const DIRS = ["src/backend", "electron"];
const PATTERNS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/_ENV\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
];
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "tests") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (
/\.(ts|cjs|mjs|js)$/.test(entry.name) &&
!/\.test\./.test(entry.name)
)
out.push(full);
}
return out;
}
function readNames(root = ROOT) {
const found = new Map();
for (const dir of DIRS) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of PATTERNS) {
for (const match of text.matchAll(pattern)) {
if (!found.has(match[1]))
found.set(match[1], path.relative(root, file));
}
}
}
}
return found;
}
function main() {
const catalog = JSON.parse(
fs.readFileSync(path.join(ROOT, "env.catalog.json"), "utf8"),
);
const listed = new Set(catalog.vars.map((v) => v.name));
const problems = [];
for (const v of catalog.vars) {
if (!catalog.groups[v.group])
problems.push(`${v.name}: unknown group "${v.group}"`);
if (!v.description?.trim()) problems.push(`${v.name}: no description`);
}
for (const [name, file] of readNames()) {
if (!listed.has(name))
problems.push(
`${name} is read in ${file} but missing from env.catalog.json`,
);
}
if (problems.length) {
for (const p of problems) console.error(` ${p}`);
process.exit(1);
}
console.log(`env catalog ok (${listed.size} vars)`);
}
if (require.main === module) main();
module.exports = { readNames, PATTERNS };
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { readNames } = require("./check-env-catalog.cjs") as {
readNames: (root: string) => Map<string, string>;
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "env-catalog-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("readNames", () => {
it("finds every way core reads an env var", () => {
const dir = fixture({
"src/backend/a.ts": [
"const a = process.env.FIRST_VAR;",
'const b = process.env["SECOND_VAR"];',
"const c = env.THIRD_VAR;",
'export const X_ENV =\n "FOURTH_VAR";',
].join("\n"),
"electron/main.cjs": "process.env.DESKTOP_VAR",
});
expect([...readNames(dir).keys()].sort()).toEqual([
"DESKTOP_VAR",
"FIRST_VAR",
"FOURTH_VAR",
"SECOND_VAR",
"THIRD_VAR",
]);
});
it("skips tests", () => {
const dir = fixture({
"src/backend/tests/a.test.ts": "process.env.ONLY_IN_TESTS",
"src/backend/b.test.ts": "process.env.ALSO_TESTS",
});
expect(readNames(dir).size).toBe(0);
});
});
+2
View File
@@ -79,6 +79,8 @@ const ALLOWED = [
"DialogPortal",
"DialogTitle",
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
-126
View File
@@ -1,126 +0,0 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
function isSourceFile(file) {
return file.path === `/${SOURCE_FILE}`;
}
async function resolveFileIds(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const ids = files.filter(isSourceFile).map((f) => f.id);
if (ids.length === 0)
throw new Error(`no "${SOURCE_FILE}" source files found in project`);
return ids;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileIds = await resolveFileIds(projectId);
console.log(
`Pre-translating project ${projectId}, ${fileIds.length} files, ${targetLanguageIds.length} languages`,
);
// Translation memory first so strings that moved between files keep their
// existing translations, then machine translation for whatever is left.
const passes = [
{ method: "tm", translateUntranslatedOnly: true },
{ method: "mt", engineId: ENGINE_ID, scope: "untranslated" },
];
for (const pass of passes) {
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{ languageIds: targetLanguageIds, fileIds, ...pass },
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(
`${pass.method} pre-translation finished (${result.progress}%)`,
);
}
}
module.exports = {
resolveProjectId,
resolveFileIds,
isSourceFile,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
isSourceFile: (file: { path?: string }) => boolean;
};
describe("isSourceFile", () => {
it("matches the core source file", () => {
expect(isSourceFile({ path: "/en.json" })).toBe(true);
});
it("skips anything else", () => {
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
false,
);
expect(isSourceFile({})).toBe(false);
});
});
+8 -3
View File
@@ -45,7 +45,10 @@ import {
} from "./lib/dev-docker.mjs";
const require = createRequire(import.meta.url);
const { copyLocalBuild } = require("./lib/bundled-plugins.cjs");
const {
copyLocalBuild,
localSdkBuildCommand,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BACKEND_ENTRY = path.join(
@@ -83,7 +86,9 @@ function run(command, args, cwd = root) {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "inherit", "inherit"],
shell: process.platform === "win32",
// npm is a .cmd on Windows and needs the shell, but the shell splits
// arguments on spaces, so a path like "Personal Projects" gets cut.
shell: process.platform === "win32" && !path.isAbsolute(command),
});
child.on("error", reject);
child.on("exit", (code) =>
@@ -541,7 +546,7 @@ function watchCoreForDocker() {
async function rebuildPlugin({ id, repo }) {
log(`building ${id}`);
try {
await run("npm", ["run", "build"], repo);
await run(...localSdkBuildCommand(), repo);
} catch (error) {
warn(`${id} failed to build: ${error.message}`);
return;
+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 — and the sqlite definitions supply both correctly for
* object: the identifiers to interpolate, and the encoders that turn JS values
* into driver values. 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 — MySQL cannot index unbounded TEXT", () => {
it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
expect(out).toContain('varchar("user_id", { length: 255 })');
expect(out).toContain('text("name")');
});
+73 -99
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,124 +10,98 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
const svgBuffer = readFileSync(join(publicDir, "icon.svg"));
const svgBuffer = readFileSync(join(publicDir, "icon-square.svg"));
const render = (size) =>
sharp(svgBuffer, { density: 72 * Math.max(1, size / 1024) })
.resize(size, size)
.png({ compressionLevel: 9 })
.toBuffer();
const pngSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
const png = Object.fromEntries(
await Promise.all(pngSizes.map(async (s) => [s, await render(s)])),
);
console.log("Generating PNG icons...");
await Promise.all(
pngSizes.map((size) =>
sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(join(iconsDir, `${size}x${size}.png`))
.then(() => console.log(` ✓ icons/${size}x${size}.png`)),
for (const size of pngSizes) {
writeFileSync(join(iconsDir, `${size}x${size}.png`), png[size]);
}
writeFileSync(join(publicDir, "icon.png"), png[1024]);
writeFileSync(join(publicDir, "full-icon.png"), png[1024]);
writeFileSync(join(publicDir, "icon-mac.png"), png[512]);
writeFileSync(
join(publicDir, "favicon.ico"),
buildIco(
[16, 32, 48].map((s) => png[s]),
[16, 32, 48],
),
);
// icon.png (1024x1024) for Linux electron-builder
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "icon.png"));
console.log(" ✓ icon.png");
// icon-mac.png (512x512) for macOS
await sharp(svgBuffer)
.resize(512, 512)
.png()
.toFile(join(publicDir, "icon-mac.png"));
console.log(" ✓ icon-mac.png");
// full-icon.png (1024x1024) used in app UI
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png");
// favicon.ico — embed 16, 32, 48 px layers
console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
icoSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
);
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico");
// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers
console.log("Generating icon.ico...");
const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all(
winSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
writeFileSync(
join(publicDir, "icon.icns"),
buildIcns([
["icp4", png[16]],
["icp5", png[32]],
["icp6", png[64]],
["ic07", png[128]],
["ic08", png[256]],
["ic09", png[512]],
["ic10", png[1024]],
["ic11", png[32]],
["ic12", png[64]],
["ic13", png[256]],
["ic14", png[512]],
]),
);
writeFileSync(join(publicDir, "icon.ico"), buildIco(winBuffers, winSizes));
console.log(" ✓ icon.ico");
// icons/icon.ico and icons/icon.icns placeholders (stubs pointing to source)
// electron-builder generates .icns; copy the 1024 PNG as icons/icon.png for reference
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(iconsDir, "icon.ico").replace("icon.ico", "1024x1024.png"));
// Copy icon.ico and icon.icns into icons/ as well
import { copyFileSync } from "fs";
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
console.log(" ✓ icons/icon.ico");
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
console.log("Icons generated from public/icon-square.svg");
/**
* Builds a minimal ICO file from PNG buffers.
* @param {Buffer[]} pngBuffers
* @param {number[]} sizes
* @returns {Buffer}
*/
function buildIco(pngBuffers, sizes) {
const count = pngBuffers.length;
const headerSize = 6;
const dirEntrySize = 16;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const entries = pngBuffers.map((buf, i) => {
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
const entry = { size: sizes[i], buf, offset };
offset += buf.length;
return entry;
});
const totalSize = offset;
const ico = Buffer.alloc(totalSize);
// ICO header
ico.writeUInt16LE(0, 0); // reserved
ico.writeUInt16LE(1, 2); // type: 1 = ICO
ico.writeUInt16LE(count, 4); // image count
// Directory entries
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
ico.writeUInt8(e.width, base); // width (0 = 256)
ico.writeUInt8(e.height, base + 1); // height (0 = 256)
ico.writeUInt8(0, base + 2); // color count
ico.writeUInt8(0, base + 3); // reserved
ico.writeUInt16LE(1, base + 4); // planes
ico.writeUInt16LE(32, base + 6); // bit count
ico.writeUInt32LE(e.buf.length, base + 8); // size of image data
ico.writeUInt32LE(e.offset, base + 12); // offset of image data
ico.writeUInt8(e.size > 255 ? 0 : e.size, base);
ico.writeUInt8(e.size > 255 ? 0 : e.size, base + 1);
ico.writeUInt16LE(1, base + 4);
ico.writeUInt16LE(32, base + 6);
ico.writeUInt32LE(e.buf.length, base + 8);
ico.writeUInt32LE(e.offset, base + 12);
e.buf.copy(ico, e.offset);
});
// Image data
entries.forEach((e) => e.buf.copy(ico, e.offset));
return ico;
}
function buildIcns(chunks) {
const parts = chunks.map(([type, data]) => {
const head = Buffer.alloc(8);
head.write(type, 0, "ascii");
head.writeUInt32BE(data.length + 8, 4);
return Buffer.concat([head, data]);
});
const body = Buffer.concat(parts);
const head = Buffer.alloc(8);
head.write("icns", 0, "ascii");
head.writeUInt32BE(body.length + 8, 4);
return Buffer.concat([head, body]);
}
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff. Show more