Compare commits

..
19 Commits
Author SHA1 Message Date
LukeGus 0324d27f13 fix: folder rows no longer nest action buttons inside a button 2026-10-08 15:28:11 -05:00
LukeGus ab2307bab4 fix: tab buttons like share show as soon as the terminal is ready 2026-10-08 14:04:27 -05:00
LukeGus 5e7f69456b fix: manage tab refetches before giving up on a host or credential it cannot find 2026-10-08 14:04:23 -05:00
LukeGus 2f975f433e fix: keep plugin tailwind variants in core's layer so overrides keep their order 2026-10-08 13:54:37 -05:00
LukeGus 4511cc0a42 fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK 2026-10-08 13:38:56 -05:00
LukeGus 878f1ebaa3 fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 2026-10-08 13:38:52 -05:00
LukeGus d6c4ec01b5 fix: lighter confirm prompt overlay to stop lag when it opens 2026-10-08 00:51:45 -05:00
LukeGus f09865ed04 fix: header separators render as borders and center the status divider 2026-10-08 00:49:11 -05:00
LukeGus cfab6f764c feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 2026-10-08 00:34:50 -05:00
LukeGus 7fcda3bb01 chore: update translation guidance, only en.json needs editing 2026-10-07 22:38:33 -05:00
LukeGus 7161f37984 chore: add LICENSE and use Node 24 from .nvmrc 2026-10-07 22:33:18 -05:00
LukeGus f57c7e8966 chore: remove unused knip config 2026-10-07 22:25:47 -05:00
LukeGus 5774f7cd0e chore: standardize repo setup files 2026-10-07 22:19:20 -05:00
LukeGus 5819348064 feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) 2026-10-07 21:39:31 -05:00
LukeGus c3624cdf62 fix: refuse sessionless tokens and keep the secrets file owner only 2026-10-07 21:20:03 -05:00
LukeGus 740d2adaf3 fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) 2026-10-07 21:18:08 -05:00
LukeGus 8a45d952fd fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) 2026-10-07 21:17:07 -05:00
LukeGus ad3a18224f fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) 2026-10-07 21:14:34 -05:00
LukeGus cd29e1d087 chore: name core and sdk releases Termix vX and Plugin SDK vX 2026-10-07 20:28:54 -05:00
121 changed files with 3003 additions and 767 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]
}
}
-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)
+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
+14 -2
View File
@@ -462,7 +462,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 +500,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 +591,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
}
+2
View File
@@ -5,6 +5,8 @@
### Added
- Temp
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
### Changed
+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.
-7
View File
@@ -1,7 +0,0 @@
{
"drips": {
"ethereum": {
"ownedBy": "0x67e0C779119D9BcC2187564A66B80a58767d05d1"
}
}
}
+199 -10
View File
@@ -1,13 +1,202 @@
Copyright 2025 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.
+1 -1
View File
@@ -254,4 +254,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.
+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.
+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."
}
]
}
-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"]
}
}
}
+2 -35
View File
@@ -142,7 +142,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 +158,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 +167,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 +1089,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 +2697,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"
@@ -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.1.0",
"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",
+3 -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",
@@ -166,10 +166,7 @@
"eslint --fix",
"prettier --write"
],
"*.{js,jsx}": [
"prettier --write"
],
"*.{json,css,md}": [
"*.{js,jsx,mjs,cjs,json,css,md}": [
"prettier --write"
]
},
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 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.
+3 -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 />
@@ -30,6 +30,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin test # run the plugin's Vitest suite
npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin 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 +40,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.
+37 -12
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,15 @@ 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");
/**
* Compiles the Tailwind classes the plugin's frontend uses into
@@ -116,15 +119,23 @@ const THEME_CSS = path.join(
* 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.
* 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) {
export function lowerPluginLayers(css, { utilities = true } = {}) {
const layers = utilities ? "properties|utilities" : "properties";
return css.replace(
/@layer (properties|utilities)(?=[\s{;,])/g,
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")));
}
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.",
@@ -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.
+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 };
}
+10 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.2",
"version": "1.0.4",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -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",
@@ -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,
+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.*).
+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",
+3 -1
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
@@ -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>>
+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",
+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;
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Writes openapi.json for core from the @openapi blocks in src/backend.
// The docs site runs the same builder on each release.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildOpenApi } from "@termix-ssh/plugin-sdk/openapi";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(
fs.readFileSync(path.join(root, "package.json"), "utf8"),
);
const tags = JSON.parse(
fs.readFileSync(
path.join(root, "src/backend/utils/openapi-tags.json"),
"utf8",
),
);
const out = process.argv[2] ?? path.join(root, "openapi.json");
const spec = await buildOpenApi({
title: "Termix API",
version: pkg.version,
description: "The core Termix API. Each plugin documents its own routes.",
files: [path.join(root, "src/backend/**/!(*.test).ts")],
tags,
});
fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
console.log(
`wrote ${path.relative(root, out)} (${Object.keys(spec.paths ?? {}).length} paths)`,
);
+27 -2
View File
@@ -299,14 +299,37 @@ function newestMtime(target) {
}
/** True when a plugin repo has never been built or its sources changed since. */
function isLocalBuildStale(repo) {
/** The SDK CLI in this checkout, which dev builds local plugins with. */
const LOCAL_SDK_CLI = path.join(
__dirname,
"..",
"..",
"packages",
"plugin-sdk",
"cli",
);
/**
* True when the build is missing, a source is newer than it, or the SDK CLI
* that builds it changed since (sdkCli, when given).
*/
function isLocalBuildStale(repo, sdkCli) {
const built = newestMtime(path.join(repo, "dist"));
if (!built) return true;
if (sdkCli && newestMtime(sdkCli) > built) return true;
return BUILD_INPUTS.some(
(entry) => newestMtime(path.join(repo, entry)) > built,
);
}
/**
* The command that builds a plugin repo with this checkout's SDK instead of
* the published one in its node_modules, so SDK edits apply right away.
*/
function localSdkBuildCommand() {
return [process.execPath, [path.join(LOCAL_SDK_CLI, "index.mjs"), "build"]];
}
/**
* Builds each plugin repo whose build is missing or out of date. ids limits
* it to those plugins; null builds every one.
@@ -325,7 +348,8 @@ function rebuildLocalPlugins(dir, ids, run) {
} catch {
continue;
}
if ((ids && !ids.has(id)) || !isLocalBuildStale(repo)) continue;
if ((ids && !ids.has(id)) || !isLocalBuildStale(repo, LOCAL_SDK_CLI))
continue;
run(repo, id);
rebuilt.push(id);
}
@@ -346,6 +370,7 @@ function copyLocalBuild(repo, destination) {
module.exports = {
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
parseBundledPlugins,
+74
View File
@@ -7,6 +7,10 @@ import {
build,
lowerPluginLayers,
} from "../packages/plugin-sdk/cli/commands/build.mjs";
import {
classNamesInCss,
splitByCoreClasses,
} from "../packages/plugin-sdk/cli/lib/css-classes.mjs";
const cleanups: Array<() => void> = [];
@@ -102,6 +106,76 @@ describe("plugin CSS layers", () => {
);
});
it("can leave a plugin's own classes in core's utilities layer", () => {
expect(
lowerPluginLayers(
"@layer properties{a{b:c}}@layer utilities{.w-px{width:1px}}",
{ utilities: false },
),
).toBe(
"@layer termix-plugin-properties{a{b:c}}@layer utilities{.w-px{width:1px}}",
);
});
it("reads class names from selectors, not declarations", () => {
const names = classNamesInCss(
String.raw`.w-28{width:calc(var(--spacing) * 28)}@media (width>=48rem){.md\:w-44{width:0.5rem}}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.top-1\/2{top:50%}.\32 xl\:flex{display:flex}`,
);
expect([...names].sort()).toEqual(
[
"2xl:flex",
"group",
"group-hover:opacity-100",
"md:w-44",
"top-1/2",
"w-28",
].sort(),
);
});
it("splits a plugin's classes by whether core ships them", () => {
expect(
splitByCoreClasses(
["w-28", "md:w-44", "hidden"],
new Set(["w-28", "hidden"]),
),
).toEqual({ shared: ["w-28", "hidden"], own: ["md:w-44"] });
});
it("keeps variants core ships with the plugin's own classes", () => {
expect(
splitByCoreClasses(
["max-h-56", "md:max-h-none", "flex-col", "md:flex-row"],
new Set(["md:max-h-none", "flex-col", "md:flex-row"]),
),
).toEqual({
shared: ["flex-col"],
own: ["max-h-56", "md:max-h-none", "md:flex-row"],
});
});
it("keeps classes core ships below core and the plugin's own beside core's", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
// Core's class list comes from scanning this repo, this file included,
// so the plugin-only class is put together here instead of written out.
const width = ["w-", "[321px]"].join("");
const dir = fixturePlugin();
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
fs.writeFileSync(
path.join(dir, "src", "frontend", "index.tsx"),
`export const a = <div className="hidden md:${width}" />;\n`,
);
await build({ cwd: dir });
const css = fs.readFileSync(path.join(dir, "dist", "frontend.css"), "utf8");
const lowered = css.slice(css.indexOf("@layer termix-plugin-utilities"));
const own = css.slice(css.lastIndexOf("@layer utilities{"));
expect(lowered).toMatch(/^@layer termix-plugin-utilities\{[^@]*\.hidden\{/);
expect(own).toContain(String.raw`.md\:w-\[321px\]`);
expect(own).not.toContain(".hidden{");
});
it("is ordered in core's CSS between base and core's utilities", () => {
const css = fs.readFileSync(
path.join(__dirname, "..", "src", "ui", "index.css"),
+2 -1
View File
@@ -75,7 +75,8 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
if (SHARED_VENDOR_MODULES.includes(specifier)) {
return vendorFacade(repoRoot, specifier);
}
if (specifier === "@termix-ssh/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
if (specifier === "@termix-ssh/plugin-sdk/ui")
return sourceFacade(sdkUiEntry);
if (specifier === "@termix-ssh/plugin-sdk/frontend") {
return sourceFacade(sdkFrontendEntry);
}
+53 -22
View File
@@ -14,11 +14,7 @@ import type { Request, Response } from "express";
import { AuthManager } from "../utils/auth-manager.js";
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
import { authLogger } from "../utils/logger.js";
import {
generateDeviceFingerprint,
getDeviceId,
parseUserAgent,
} from "../utils/user-agent-parser.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import {
createCurrentUserAuthRepository,
@@ -37,6 +33,7 @@ import {
type SecondFactor,
} from "./registry.js";
import {
isNativeAppRequest,
issueSession,
sendSession,
syncSharedCredentialsForUserRoles,
@@ -131,10 +128,33 @@ function shouldRunSecondFactors(methodId: string): boolean {
return isSecondFactorAfterExternalLoginEnabled();
}
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
/**
* The remember-this-device token the server handed out after a full login.
* Browsers carry it in an HttpOnly cookie; the desktop app, which talks to
* the server cross-origin without cookies, sends it back as a header.
*/
function readTrustToken(req: Request): string | null {
const cookies = (req as Request & { cookies?: Record<string, string> })
.cookies;
const value =
cookies?.[TRUST_DEVICE_COOKIE] || req.get("x-termix-trust-token");
return typeof value === "string" && TRUST_TOKEN_PATTERN.test(value)
? value
: null;
}
export function hashTrustToken(token: string): string {
return crypto.createHash("sha256").update(`trust-v2|${token}`).digest("hex");
}
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
const deviceInfo = parseUserAgent(req);
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
if (!fingerprint) return false;
const token = readTrustToken(req);
if (!token) return false;
const fingerprint = hashTrustToken(token);
const trusted = await AuthManager.getInstance().isTrustedDevice(
userId,
fingerprint,
@@ -473,22 +493,29 @@ export async function verifySecondFactorAndRespond(
? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false);
let trustToken: string | null = null;
if (rememberMe) {
const deviceInfo = parseUserAgent(req);
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
if (fingerprint) {
await AuthManager.getInstance().addTrustedDevice(
user.id,
fingerprint,
deviceInfo.type,
deviceInfo.deviceInfo,
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: user.id,
deviceType: deviceInfo.type,
});
}
trustToken = crypto.randomBytes(32).toString("hex");
await AuthManager.getInstance().addTrustedDevice(
user.id,
hashTrustToken(trustToken),
deviceInfo.type,
deviceInfo.deviceInfo,
);
res.cookie(
TRUST_DEVICE_COOKIE,
trustToken,
AuthManager.getInstance().getSecureCookieOptions(
req,
TRUST_TOKEN_MAX_AGE_MS,
),
);
authLogger.info("Device automatically trusted via Remember Me", {
operation: "totp_auto_trust",
userId: user.id,
deviceType: deviceInfo.type,
});
}
const pending = lookup.pending;
@@ -498,6 +525,10 @@ export async function verifySecondFactorAndRespond(
externalSession: pending?.externalSession ?? null,
});
if (trustToken && isNativeAppRequest(req)) {
session.body.trustToken = trustToken;
}
consumePendingLogin(lookup.token);
res.clearCookie(
PENDING_LOGIN_COOKIE,
+10 -13
View File
@@ -81,6 +81,10 @@ import {
} from "./repositories/factory.js";
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import {
selectExportableSettings,
type SettingData,
} from "../utils/export-settings.js";
import {
keepUsableProtocolCredentials,
listProtocolLogins,
@@ -107,19 +111,9 @@ const requireAdmin = authManager.createAdminMiddleware();
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
type SettingData = {
key: string;
value: string;
};
function shouldExportSetting(key: string): boolean {
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
}
async function getExportableSettings(): Promise<SettingData[]> {
async function getExportableSettings(isAdmin: boolean): Promise<SettingData[]> {
const settingsRows = await createCurrentSettingsRepository().listAll();
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
return selectExportableSettings(settingsRows, isAdmin);
}
function writeSettingsToExportDatabase(
@@ -1047,7 +1041,10 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
writeSettingsToExportDatabase(
exportDb,
await getExportableSettings(!!user.isAdmin),
);
await writeHostDefaultsToExport(exportDb, userId);
} finally {
exportDb.close();
+15 -1
View File
@@ -19,7 +19,13 @@ import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
import {
pluginDocsUrl,
pluginEnvVars,
pluginFeatures,
youtubeVideoId,
type PluginEnvVar,
} from "../../plugins/manifest.js";
import {
isPluginChoice,
type PluginChoice,
@@ -259,6 +265,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let repository: string | undefined;
let videoId: string | undefined;
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
@@ -269,6 +277,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
repository?: unknown;
video?: unknown;
features?: unknown;
docs?: unknown;
env?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
@@ -292,6 +302,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
: undefined;
videoId = youtubeVideoId(manifest?.video) ?? undefined;
features = pluginFeatures(manifest?.features);
docs = pluginDocsUrl(manifest?.docs) ?? undefined;
env = pluginEnvVars(manifest?.env);
} catch {
contributes = null;
}
@@ -306,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
icon,
dependencies,
optionalDependencies,
docs,
...describePluginFrontend(loaded),
};
@@ -324,6 +337,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
repository,
videoId,
features,
env,
signedBy: loaded?.signedBy ?? null,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
+6 -1
View File
@@ -11,6 +11,7 @@ import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isDesktopAppOrigin } from "../../utils/cors-config.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
allowsDesktopAutoSession,
@@ -744,7 +745,11 @@ router.get("/setup-required", async (req, res) => {
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
if (
!allowsDesktopAutoSession() ||
!isLoopbackRequest(req) ||
!isDesktopAppOrigin(req.headers.origin)
) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
+2
View File
@@ -222,6 +222,7 @@ export interface RegistryListing {
icon?: string;
videoId?: string;
features: string[];
docs?: string;
versions: Array<{
version: string;
prerelease: boolean;
@@ -290,6 +291,7 @@ export async function listRegistry(
icon: plugin.icon,
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
+3
View File
@@ -19,10 +19,13 @@ export {
qualifyPermission,
youtubeVideoId,
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
PluginManifest,
PluginEnvVar,
PluginAuthor,
PluginEngine,
PluginContributions,
+4
View File
@@ -13,6 +13,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
@@ -67,6 +68,8 @@ export interface RegistryPlugin {
/** Only ever a YouTube video id, never a link. */
videoId?: string;
features: string[];
/** https docs link from the index. */
docs?: string;
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
@@ -161,6 +164,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
icon: asString(entry.icon),
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
versions,
prereleases,
});
@@ -235,7 +235,6 @@ export function fakeAuthManager(state: AuthState) {
export function fakeRequest(overrides: Record<string, unknown> = {}) {
const headers: Record<string, string> = {
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
"x-termix-device-id": "a".repeat(64),
...(overrides.headers as Record<string, string> | undefined),
};
return {
+56 -5
View File
@@ -88,6 +88,8 @@ const {
respondWithRedirectLogin,
verifySecondFactorAndRespond,
resetPendingLoginsForTests,
TRUST_DEVICE_COOKIE,
hashTrustToken,
} = await import("../../auth/login-pipeline.js");
const { verifyPasswordLogin } =
await import("../../auth/builtin-login-methods.js");
@@ -295,16 +297,46 @@ describe("second factors", () => {
await verifySecondFactorAndRespond(req as never, res as never, "totp");
expect(res.statusCode).toBe(200);
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
const trust = res.cookies.find((c) => c.name === TRUST_DEVICE_COOKIE);
expect(trust?.value).toMatch(/^[a-f0-9]{64}$/);
expect(res.cookies.find((c) => c.name === "jwt")).toBeDefined();
expect(res.body).toMatchObject({
success: true,
username: "alice",
totp_enabled: true,
});
expect(h.state.trustedAdded).toHaveLength(1);
expect(res.body).not.toHaveProperty("trustToken");
expect(h.state.trustedAdded).toEqual([
`u1:${hashTrustToken(trust!.value)}`,
]);
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
});
it("hands the trust token to the desktop app in the body", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
headers: { "x-electron-app": "true" },
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect((res.body as { trustToken: string }).trustToken).toMatch(
/^[a-f0-9]{64}$/,
);
});
it("answers the 2.8 route with the user's first factor when none is named", async () => {
enrolTotp();
const first = await passwordLogin({
@@ -369,13 +401,32 @@ describe("second factors", () => {
expect(res.cookies).toEqual([]);
});
it("skips the factor on a trusted device", async () => {
it("skips the factor with a server-issued trust cookie", async () => {
enrolTotp();
h.manager.isTrustedDevice.mockResolvedValueOnce(true);
const res = await passwordLogin({ username: "alice", password: PASSWORD });
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
const res = fakeResponse();
const identity = await verifyPasswordLogin(req as never);
await respondWithLogin(req as never, res as never, identity, {
methodId: "password",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("never skips the factor on a client-made device id", async () => {
enrolTotp();
const deviceId = "a".repeat(64);
h.state.trusted.add(`u1:${deviceId}`);
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.body).toMatchObject({ requires_totp: true });
expect(h.manager.isTrustedDevice).not.toHaveBeenCalled();
});
it("fails closed when an enrolled factor's plugin is gone", async () => {
addUser();
h.state.factors.push({
@@ -14,7 +14,10 @@ import {
isTermixCompatible,
youtubeVideoId,
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
} from "../../plugins/manifest.js";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
function base(overrides: Record<string, unknown> = {}) {
return {
@@ -929,3 +932,73 @@ describe("youtubeVideoId", () => {
expect(youtubeVideoId(undefined)).toBeNull();
});
});
describe("docs and env fields", () => {
it("accepts an https docs link and well formed env entries", () => {
expect(
validateManifest(
base({
docs: "https://docs.termix.site/plugins/sample-plugin",
env: [
{ name: "SAMPLE_URL", description: "Where to connect." },
{
name: "SAMPLE_TOKEN",
description: "Token.",
secret: true,
required: true,
default: "",
},
],
}),
),
).toEqual([]);
});
it("refuses a docs link that is not https", () => {
const errors = validateManifest(base({ docs: "http://example.com" }));
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
});
it("refuses bad env entries", () => {
const errors = validateManifest(
base({
env: [
{ name: "lower_case", description: "x" },
{ name: "DUP", description: "x" },
{ name: "DUP", description: "x" },
{ name: "NO_DESC", description: "" },
{ name: "EXTRA", description: "x", other: 1 },
{ name: "FLAG", description: "x", secret: "yes" },
],
}),
).join(" ");
expect(errors).toMatch(/env\[0\]\.name/);
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
expect(errors).toMatch(/env\[3\]\.description/);
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
expect(errors).toMatch(/env\[5\]\.secret/);
});
it("reads docs links and env entries leniently", () => {
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
"https://docs.termix.site/plugins/x",
);
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
expect(pluginDocsUrl(42)).toBeNull();
expect(
pluginEnvVars([
{ name: "OK_VAR", description: "fine" },
{ name: "bad", description: "x" },
"nope",
]),
).toEqual([{ name: "OK_VAR", description: "fine" }]);
});
it("joins pages and anchors onto a docs link", () => {
const base = "https://docs.termix.site/plugins/docker";
expect(pluginDocsPage(base)).toBe(base);
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
});
});
@@ -155,6 +155,23 @@ describe("parseRegistryIndex", () => {
expect(index.plugins[1].features).toEqual([]);
});
it("keeps only https docs links", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
docs: "https://docs.termix.site/plugins/docker/",
versions: [version()],
},
{ id: "other", docs: "javascript:alert(1)", versions: [version()] },
],
});
expect(index.plugins[0].docs).toBe(
"https://docs.termix.site/plugins/docker",
);
expect(index.plugins[1].docs).toBeUndefined();
});
it("refuses something that is not an index", () => {
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildOpenApi,
hasOpenApiPaths,
TERMIX_SECURITY_SCHEMES,
} from "@termix-ssh/plugin-sdk/openapi";
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
function source(files: Record<string, string>): string {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-openapi-"));
for (const [name, text] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), text);
}
return dir;
}
describe("buildOpenApi", () => {
it("reads @openapi blocks from TypeScript sources", async () => {
const root = source({
"routes.ts": [
"/**",
" * @openapi",
" * /plugin-api/sample/items:",
" * get:",
" * summary: List items",
" * responses:",
" * 200:",
" * description: The items",
" */",
'router.get("/items", handler);',
].join("\n"),
});
const spec = await buildOpenApi({
title: "Sample API",
version: "1.2.3",
files: [path.join(root, "*.ts")],
tags: [{ name: "Sample" }],
});
expect(hasOpenApiPaths(spec)).toBe(true);
expect(Object.keys(spec.paths ?? {})).toEqual(["/plugin-api/sample/items"]);
expect(spec).toMatchObject({
openapi: "3.0.3",
info: { title: "Sample API", version: "1.2.3" },
tags: [{ name: "Sample" }],
components: { securitySchemes: TERMIX_SECURITY_SCHEMES },
});
});
it("returns a spec with no paths when nothing is documented", async () => {
const root = source({ "index.ts": "export const x = 1;" });
const spec = await buildOpenApi({
title: "Empty",
version: "1.0.0",
files: [path.join(root, "*.ts")],
});
expect(hasOpenApiPaths(spec)).toBe(false);
});
});
@@ -19,8 +19,14 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
// No sessionId in our tokens, so the session branch is skipped.
createCurrentSessionRepository: () => ({ findById: async () => null }),
createCurrentSessionRepository: () => ({
findById: async (id: string) => ({
id,
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
}),
touch: async () => {},
revoke: async () => {},
}),
createCurrentUserRepository: () => ({
findById: async (userId: string) => state.users.get(userId) ?? null,
}),
@@ -128,7 +134,9 @@ function runMiddleware(token: string, headers: Record<string, string> = {}) {
}
function token(userId: string) {
return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" });
return jwt.sign({ userId, sessionId: `s-${userId}` }, jwtSecret, {
expiresIn: "1h",
});
}
beforeEach(() => {
@@ -18,7 +18,9 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({}),
createCurrentSessionRepository: () => ({
findById: async (id: string) => ({ id }),
}),
createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}),
createCurrentTrustedDeviceRepository: () => ({}),
@@ -58,7 +60,7 @@ function makeLegacyDataKeyWrap(
): Record<string, string> {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
cipher.setAAD(Buffer.from(`${userId}:`, "utf8"));
cipher.setAAD(Buffer.from(`${userId}:s1`, "utf8"));
const data = Buffer.concat([cipher.update(dek), cipher.final()]);
return {
version: "v1",
@@ -85,7 +87,11 @@ describe("AuthManager token handling", () => {
it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => {
const dek = crypto.randomBytes(32);
const token = jwt.sign(
{ userId: "user-1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek) },
{
userId: "user-1",
sessionId: "s1",
dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek),
},
jwtSecret,
{ expiresIn: "1h" },
);
@@ -107,6 +113,7 @@ describe("AuthManager token handling", () => {
const token = jwt.sign(
{
userId: "user-1",
sessionId: "s1",
dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)),
},
jwtSecret,
@@ -123,13 +130,32 @@ describe("AuthManager token handling", () => {
wrap.tag = Buffer.from(
Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff),
).toString("base64url");
const token = jwt.sign({ userId: "user-1", dataKeyWrap: wrap }, jwtSecret, {
expiresIn: "1h",
});
const token = jwt.sign(
{ userId: "user-1", sessionId: "s1", dataKeyWrap: wrap },
jwtSecret,
{
expiresIn: "1h",
},
);
const payload = await authManager.verifyJWTToken(token);
expect(payload?.userId).toBe("user-1");
expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled();
});
it("rejects a signed token with no session behind it", async () => {
const token = jwt.sign({ userId: "user-1" }, jwtSecret, {
expiresIn: "1h",
});
expect(await authManager.verifyJWTToken(token)).toBeNull();
});
it("still accepts the sessionless pending second-factor token", async () => {
const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, {
expiresIn: "10m",
});
const payload = await authManager.verifyJWTToken(token);
expect(payload?.pendingTOTP).toBe(true);
});
});
+42 -1
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
import type { Request } from "express";
import { isCorsOriginAllowed } from "../../utils/cors-config.js";
import {
isCorsOriginAllowed,
isDesktopAppOrigin,
} from "../../utils/cors-config.js";
function request(headers: Record<string, string> = {}): Request {
return {
@@ -11,6 +14,7 @@ function request(headers: Record<string, string> = {}): Request {
afterEach(() => {
delete process.env.CORS_ALLOWED_ORIGINS;
delete process.env.ELECTRON_EMBEDDED;
});
describe("isCorsOriginAllowed", () => {
@@ -41,4 +45,41 @@ describe("isCorsOriginAllowed", () => {
const req = request({ host: "termix.example" });
expect(isCorsOriginAllowed(req, "https://attacker.example")).toBe(false);
});
describe("embedded desktop backend", () => {
it("rejects other websites even with no allowlist", () => {
process.env.ELECTRON_EMBEDDED = "true";
const req = request({ host: "127.0.0.1:30001" });
expect(isCorsOriginAllowed(req, "https://evil.example")).toBe(false);
});
it("still allows the desktop app and the dev server", () => {
process.env.ELECTRON_EMBEDDED = "true";
const req = request({ host: "127.0.0.1:30001" });
expect(isCorsOriginAllowed(req, "file://")).toBe(true);
expect(isCorsOriginAllowed(req, "http://localhost:5173")).toBe(true);
expect(isCorsOriginAllowed(req, undefined)).toBe(true);
});
it("allows an explicitly configured origin", () => {
process.env.ELECTRON_EMBEDDED = "true";
process.env.CORS_ALLOWED_ORIGINS = "https://portal.example";
expect(isCorsOriginAllowed(request(), "https://portal.example")).toBe(
true,
);
});
});
});
describe("isDesktopAppOrigin", () => {
it("accepts no origin, file:// and the dev server", () => {
expect(isDesktopAppOrigin(undefined)).toBe(true);
expect(isDesktopAppOrigin("file://")).toBe(true);
expect(isDesktopAppOrigin("http://127.0.0.1:5173")).toBe(true);
});
it("rejects websites", () => {
expect(isDesktopAppOrigin("https://evil.example")).toBe(false);
expect(isDesktopAppOrigin("null")).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { selectExportableSettings } from "../../utils/export-settings.js";
const rows = [
{ key: "allow_registration", value: "true" },
{ key: "audit_log_forward_token", value: "secret" },
{ key: "reset_code_alice", value: "123456" },
{ key: "temp_reset_token_alice", value: "abc" },
{ key: "user_dek_v3_u1", value: "{}" },
];
describe("selectExportableSettings", () => {
it("gives a non-admin export no global settings", () => {
expect(selectExportableSettings(rows, false)).toEqual([]);
});
it("keeps instance config for admins but drops secrets", () => {
expect(selectExportableSettings(rows, true).map((r) => r.key)).toEqual([
"allow_registration",
"user_dek_v3_u1",
]);
});
});
@@ -3,8 +3,6 @@ import type { Request } from "express";
import {
detectPlatform,
parseUserAgent,
generateDeviceFingerprint,
getDeviceId,
} from "../../utils/user-agent-parser.js";
function reqWith(headers: Record<string, string>): Request {
@@ -97,83 +95,3 @@ describe("parseUserAgent", () => {
expect(info.os).toContain("iOS");
});
});
describe("generateDeviceFingerprint", () => {
it("is stable for the same client device id", () => {
const a = generateDeviceFingerprint(
{
type: "web",
browser: "Chrome",
version: "120.5",
os: "Windows 10/11",
deviceInfo: "Chrome 120.5 on Windows 10/11",
},
"a".repeat(64),
);
const b = generateDeviceFingerprint(
{
type: "web",
browser: "Chrome",
version: "121.9",
os: "Windows 10/11",
deviceInfo: "Chrome 121.9 on Windows 10/11",
},
"a".repeat(64),
);
expect(a).toBe(b);
});
it("differs for two clients on the same platform", () => {
const a = generateDeviceFingerprint(
{
type: "desktop",
browser: "Termix Desktop",
version: "2.7.0",
os: "Linux",
deviceInfo: "",
},
"a".repeat(64),
);
const b = generateDeviceFingerprint(
{
type: "desktop",
browser: "Termix Desktop",
version: "2.7.0",
os: "Linux",
deviceInfo: "",
},
"b".repeat(64),
);
expect(a).not.toBe(b);
});
it("does not trust clients without a device id", () => {
const fp = generateDeviceFingerprint(
{
type: "desktop",
browser: "Termix Desktop",
version: "2.3.1",
os: "macOS",
deviceInfo: "",
},
null,
);
expect(fp).toBeNull();
});
});
describe("getDeviceId", () => {
it("accepts a 256-bit hex device id", () => {
const deviceId = "a".repeat(64);
expect(getDeviceId(reqWith({ "x-termix-device-id": deviceId }))).toBe(
deviceId,
);
});
it("rejects missing or malformed device ids", () => {
expect(getDeviceId(reqWith({}))).toBeNull();
expect(
getDeviceId(reqWith({ "x-termix-device-id": "shared-linux" })),
).toBeNull();
});
});
+5 -1
View File
@@ -426,8 +426,12 @@ class AuthManager {
);
return null;
}
} else {
} else if (payload.pendingTOTP) {
await this.migrateDataKeyFromPayload(payload);
} else {
// Every real login has a session row to revoke; a signed token
// without one did not come from this server's login.
return null;
}
return payload;
} catch (error) {
+17 -3
View File
@@ -14,15 +14,28 @@ function getAllowedOrigins(): string[] {
.filter(Boolean);
}
/**
* Origins allowed to drive the embedded desktop backend: the desktop app
* itself and the dev server. Any website open in a browser on the same
* machine can reach loopback too, so nothing else gets in.
*/
export function isDesktopAppOrigin(origin: string | undefined): boolean {
if (!origin) return true;
return (
DEV_ORIGINS.includes(origin) || origin.startsWith(ELECTRON_FILE_ORIGIN)
);
}
export function isCorsOriginAllowed(
req: Request,
origin: string | undefined,
): boolean {
if (!origin) return true;
if (DEV_ORIGINS.includes(origin)) return true;
if (origin.startsWith(ELECTRON_FILE_ORIGIN)) return true;
if (isDesktopAppOrigin(origin)) return true;
const configured = getAllowedOrigins();
if (process.env.ELECTRON_EMBEDDED === "true") {
return configured.includes(origin) || origin === getRequestOrigin(req);
}
if (configured.length === 0) return true;
if (configured.includes(origin)) return true;
@@ -42,6 +55,7 @@ export function createCorsMiddleware(
"User-Agent",
"X-Electron-App",
"X-Termix-Device-ID",
"X-Termix-Trust-Token",
"Cache-Control",
"x-admin-target-user",
...extraHeaders,
+26
View File
@@ -0,0 +1,26 @@
export type SettingData = {
key: string;
value: string;
};
const SECRET_SETTING_PREFIXES = [
"reset_code_",
"temp_reset_token_",
"audit_log_forward_token",
];
/**
* Global settings are instance config, not user data: only an admin's export
* carries them (import only applies them for admins anyway), and never the
* one-time reset codes or the audit forwarding token.
*/
export function selectExportableSettings(
rows: SettingData[],
isAdmin: boolean,
): SettingData[] {
if (!isAdmin) return [];
return rows.filter(
(row) =>
!SECRET_SETTING_PREFIXES.some((prefix) => row.key.startsWith(prefix)),
);
}
+32
View File
@@ -0,0 +1,32 @@
[
{ "name": "Auth", "description": "Sign in, sign out and login methods" },
{ "name": "Users", "description": "Accounts, profiles and sessions" },
{ "name": "API Keys", "description": "API keys for scripts and the CLI" },
{ "name": "Encryption", "description": "Data key setup and unlock" },
{ "name": "SSH", "description": "Hosts, folders and connections" },
{
"name": "SSH Auth",
"description": "SSH auth types the server can connect with"
},
{
"name": "Host Defaults",
"description": "Admin, user and folder host defaults"
},
{ "name": "Host Enrollment", "description": "Adding hosts from a script" },
{ "name": "Credentials", "description": "Saved credentials" },
{ "name": "RBAC", "description": "Roles and sharing" },
{ "name": "Dashboard", "description": "Dashboard stats and recent activity" },
{ "name": "Audit", "description": "Audit log search and export" },
{ "name": "Plugins", "description": "Installing and managing plugins" },
{ "name": "Sync", "description": "Desktop sync with a linked server" },
{ "name": "Database", "description": "Database export, import and health" },
{ "name": "General", "description": "Server info and settings" },
{ "name": "Open Tabs", "description": "Saved open tabs" },
{ "name": "User Preferences", "description": "Per-user preferences" },
{ "name": "UI Preferences", "description": "Interface layout preferences" },
{ "name": "Host Sidebar", "description": "Host sidebar display options" },
{
"name": "Credential Sidebar",
"description": "Credential sidebar display options"
}
]
-176
View File
@@ -1,176 +0,0 @@
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
import path from "path";
import { fileURLToPath } from "url";
import { promises as fs } from "fs";
import { systemLogger } from "./logger.js";
type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const swaggerOptions: SwaggerJSDocOptions = {
definition: {
openapi: "3.0.3",
info: {
title: "Termix API",
version: "0.0.0",
description: "Termix Backend API Reference",
},
servers: [
{
url: "http://localhost:30001",
description: "Main database and authentication server",
},
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Session JWT sent as Authorization: Bearer <JWT>.",
},
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: "tmx_<api key>",
description:
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
},
},
schemas: {
Error: {
type: "object",
properties: {
error: { type: "string" },
details: { type: "string" },
},
},
},
},
security: [
{
bearerAuth: [],
},
{
cookieAuth: [],
},
{
apiKeyAuth: [],
},
],
tags: [
{
name: "Credentials",
description: "SSH credential management",
},
{
name: "RBAC",
description: "Role-based access control for host sharing",
},
{
name: "Users",
description: "User management and authentication",
},
{
name: "Dashboard",
description: "Dashboard statistics and activity",
},
{
name: "SSH",
description: "SSH host management and configuration",
},
{
name: "Host Enrollment",
description: "Host enrollment and onboarding",
},
{
name: "Open Tabs",
description: "Per-user open tab state",
},
{
name: "Audit",
description: "Audit log querying and export",
},
{
name: "API Keys",
description: "API key management",
},
{
name: "Sync",
description: "Remote sync between desktop and server",
},
{
name: "User Preferences",
description: "Per-user application preferences",
},
{
name: "UI Preferences",
description: "Interface layout and display preferences",
},
{
name: "Host Sidebar",
description: "Host sidebar display preferences",
},
{
name: "Credential Sidebar",
description: "Credential sidebar display preferences",
},
],
},
apis: [
path.join(__dirname, "..", "database", "database.js").replace(/\\/g, "/"),
path
.join(__dirname, "..", "database", "routes", "*.js")
.replace(/\\/g, "/"),
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
],
};
async function generateOpenAPISpec() {
try {
systemLogger.info("Generating OpenAPI specification", {
operation: "openapi_generate_start",
});
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
"openapi.json",
);
await fs.writeFile(
outputPath,
JSON.stringify(swaggerSpec, null, 2),
"utf-8",
);
systemLogger.success("OpenAPI specification generated", {
operation: "openapi_generate_success",
});
} catch (error) {
systemLogger.error("Failed to generate OpenAPI specification", error, {
operation: "openapi_generation",
});
process.exit(1);
}
}
generateOpenAPISpec();
export { swaggerOptions, generateOpenAPISpec };
+14 -3
View File
@@ -3,6 +3,16 @@ import { promises as fs } from "fs";
import path from "path";
import { databaseLogger } from "./logger.js";
/** Owner-only access for the secrets file. Not meaningful on Windows. */
export async function restrictToOwner(filePath: string): Promise<void> {
if (process.platform === "win32") return;
try {
await fs.chmod(filePath, 0o600);
} catch {
// best effort, new files are already written with 0600
}
}
class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
@@ -58,9 +68,9 @@ class SystemCrypto {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
const envContent = await fs.readFile(envPath, "utf8");
await restrictToOwner(envPath);
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
this.jwtSecret = jwtMatch[1];
@@ -349,7 +359,7 @@ class SystemCrypto {
const envPath = path.join(dataDir, ".env");
try {
await fs.mkdir(dataDir, { recursive: true });
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
let envContent = "";
@@ -370,7 +380,8 @@ class SystemCrypto {
envContent += `${key}=${value}\n`;
}
await fs.writeFile(envPath, envContent);
await fs.writeFile(envPath, envContent, { mode: 0o600 });
await restrictToOwner(envPath);
process.env[key] = value;
} catch (error) {
-20
View File
@@ -1,5 +1,4 @@
import type { Request } from "express";
import crypto from "crypto";
export type DeviceType = "web" | "desktop" | "mobile";
@@ -249,22 +248,3 @@ function parseMacVersion(userAgent: string): string {
}
return "macOS";
}
/** Return the installation-scoped identifier used for trusted-device checks. */
export function getDeviceId(req: Request): string | null {
const value = req.headers["x-termix-device-id"];
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) return null;
return value;
}
/** Bind a trusted-device record to one client installation and platform. */
export function generateDeviceFingerprint(
deviceInfo: DeviceInfo,
deviceId: string | null,
): string | null {
if (!deviceId) return null;
return crypto
.createHash("sha256")
.update(`${deviceInfo.type}|${deviceId}`)
.digest("hex");
}
+5 -1
View File
@@ -2,6 +2,7 @@ import {
authApi,
handleApiError,
markUserAuthenticated,
TRUST_TOKEN_KEY,
type AuthResponse,
} from "@/main-axios";
@@ -26,8 +27,11 @@ export type LoginResponse = AuthResponse & {
userId?: string;
};
function remember(data: LoginResponse): LoginResponse {
function remember(
data: LoginResponse & { trustToken?: string },
): LoginResponse {
if (data?.token) localStorage.setItem("jwt", data.token);
if (data?.trustToken) localStorage.setItem(TRUST_TOKEN_KEY, data.trustToken);
if (data?.success && !data.requires_totp) markUserAuthenticated();
return data;
}
+6
View File
@@ -4,6 +4,7 @@ import type {
ChangelogRelease,
ReleaseNotes,
} from "@termix-ssh/plugin-sdk/changelog";
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
export type { PluginChoice };
@@ -255,6 +256,10 @@ export interface PluginSummary {
videoId?: string;
/** What the plugin does, from the manifest. Admins only. */
features?: string[];
/** https docs link from the manifest. */
docs?: string;
/** Environment variables the plugin reads. Admins only. */
env?: PluginEnvVar[];
signedBy?: string | null;
}
@@ -294,6 +299,7 @@ export interface RegistryPluginEntry {
icon?: string;
videoId?: string;
features?: string[];
docs?: string;
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
+99
View File
@@ -0,0 +1,99 @@
/* eslint-disable react-refresh/only-export-components */
import { BookOpen } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
import { docsUrl, type DocsPage } from "@/lib/docs";
import { cn } from "@/lib/utils";
import { usePluginScope } from "@/plugin-host/scope";
import { usePluginStore } from "@/plugin-host/plugin-store";
/** A plugin's docs link from its manifest, or null when it has none. */
export function usePluginDocsUrl(
pluginId: string | null | undefined,
page?: string,
anchor?: string,
): string | null {
const { records } = usePluginStore();
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
return base ? pluginDocsPage(base, page, anchor) : null;
}
export interface DocsLinkProps {
/** A core docs page. */
core?: DocsPage;
/**
* A page of the current plugin's docs, like "setup". Inside a plugin this
* resolves against its manifest docs link; "" is its main page.
*/
page?: string;
anchor?: string;
/** A full URL, used as is. */
href?: string | null;
/** "text" is a small inline link, "icon" a square header button. */
variant?: "text" | "icon";
children?: ReactNode;
className?: string;
}
/**
* The one way the app links to docs. Renders nothing when there is nowhere
* to go, like a community plugin with no docs link.
*/
export function DocsLink({
core,
page,
anchor,
href,
variant = "text",
children,
className,
}: DocsLinkProps) {
const { t } = useTranslation();
const pluginId = usePluginScope();
const pluginUrl = usePluginDocsUrl(
page !== undefined ? pluginId : null,
page,
anchor,
);
const url =
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
if (!url) return null;
const label = t("common.docs");
if (variant === "icon") {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={label}
aria-label={label}
className={cn(
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
className,
)}
>
<BookOpen className="size-3.5" />
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"shrink-0 text-[10px] text-accent-brand hover:underline",
className,
)}
>
{children ?? label}
</a>
);
}
function withAnchor(url: string, anchor?: string): string {
if (!anchor) return url;
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
}
+15 -6
View File
@@ -5,6 +5,7 @@ import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
@@ -30,6 +31,7 @@ export function PanelShell({
tabs,
footer,
scroll = true,
docs,
className,
children,
}: {
@@ -49,6 +51,8 @@ export function PanelShell({
footer?: React.ReactNode;
/** False when the body scrolls itself. */
scroll?: boolean;
/** Docs URL for this screen, shown as a book icon in the header. */
docs?: string | null;
className?: string;
children: React.ReactNode;
}) {
@@ -101,10 +105,9 @@ export function PanelShell({
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
{leading && (
<>
<div className="flex h-full shrink-0 border-r border-border">
{leading}
<Separator orientation="vertical" />
</>
</div>
)}
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
{icon && <span className="shrink-0 text-accent-brand">{icon}</span>}
@@ -113,15 +116,21 @@ export function PanelShell({
</span>
{status && (
<>
<Separator orientation="vertical" className="h-4 self-center" />
<Separator
orientation="vertical"
className="h-4 data-[orientation=vertical]:self-center"
/>
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{status}
</span>
</>
)}
</div>
{actions && (
<div className="flex shrink-0 items-center gap-1 px-2">{actions}</div>
{(actions || docs) && (
<div className="flex shrink-0 items-center gap-1 px-2">
{actions}
{docs && <DocsLink href={docs} variant="icon" />}
</div>
)}
</header>
{bands}
+3 -5
View File
@@ -321,10 +321,8 @@ export function PanePrompt({
return (
<div
className={cn(
"motion-context-enter absolute inset-0 flex items-center justify-center overflow-y-auto p-4",
layer === "connection"
? "z-500 bg-canvas"
: "z-50 bg-background/80 backdrop-blur-sm",
"motion-fade-enter absolute inset-0 flex items-center justify-center overflow-y-auto p-4",
layer === "connection" ? "z-500 bg-canvas" : "z-50 bg-background/85",
)}
style={
layer === "connection" && backgroundColor
@@ -340,7 +338,7 @@ export function PanePrompt({
aria-modal="true"
aria-labelledby={labelledBy ?? headingId}
className={cn(
"my-auto flex w-full max-w-sm flex-col border bg-popover shadow-xl",
"motion-context-enter my-auto flex w-full max-w-sm flex-col border bg-popover shadow-xl",
tone === "destructive" ? "border-destructive/40" : "border-border",
className,
)}
+3 -1
View File
@@ -62,6 +62,7 @@ import {
type VersionStatus,
} from "./dashboard-core-cards";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { docsUrl } from "@/lib/docs";
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
@@ -803,7 +804,7 @@ export function DashboardTab({
href: "https://discord.com/invite/jVQGdvHDrf",
label: t("dashboard.discord"),
},
{ href: "https://docs.termix.site/", label: t("dashboard.docs") },
{ href: docsUrl(), label: t("dashboard.docs") },
{ href: "https://donate.termix.site/", label: t("dashboard.donate") },
];
@@ -827,6 +828,7 @@ export function DashboardTab({
title={title}
status={isDashboardView ? todayLabel : undefined}
scroll={false}
docs={docsUrl("dashboard")}
actions={
<>
{isDashboardView && !viewPending && (
+5 -1
View File
@@ -52,6 +52,10 @@ function contextKey(context: Record<string, unknown> | undefined): string {
try {
return JSON.stringify(context);
} catch {
return String(Object.keys(context));
// A value that will not serialize still counts as there or not, so a
// handle filling in changes the key.
return Object.entries(context)
.map(([key, value]) => `${key}:${value == null ? "-" : typeof value}`)
.join(",");
}
}
+12
View File
@@ -636,6 +636,11 @@ html.fs-xl {
var(--motion-ease-enter) both;
}
.motion-fade-enter {
animation: motion-fade-enter var(--motion-duration-context)
var(--motion-ease-enter) both;
}
.motion-workspace-enter {
animation: motion-workspace-enter var(--motion-duration-panel)
var(--motion-ease-enter) both;
@@ -723,6 +728,12 @@ html.fs-xl {
}
}
@keyframes motion-fade-enter {
from {
opacity: 0;
}
}
@keyframes motion-workspace-enter {
from {
opacity: 0;
@@ -783,6 +794,7 @@ html.fs-xl {
.motion-panel-enter,
.motion-context-enter,
.motion-fade-enter,
.motion-workspace-enter,
.motion-workspace-layout,
.motion-transfer-phase,
+41
View File
@@ -0,0 +1,41 @@
{
"home": "",
"install": "install",
"updating": "install/updating",
"betas": "configure/betas",
"envVars": "configure/environment-variables",
"https": "configure/https",
"database": "configure/database",
"reverseProxy": "configure/reverse-proxy",
"desktopSync": "configure/desktop-sync",
"interface": "guide/interface",
"dashboard": "guide/dashboard",
"manage": "guide/manage",
"hosts": "guide/hosts",
"portKnocking": "guide/hosts#port-knocking",
"hostDefaults": "guide/host-defaults",
"credentials": "guide/credentials",
"sharing": "guide/sharing",
"account": "guide/account",
"accountSecurity": "guide/account#security",
"accountData": "guide/account#your-data",
"apiKeys": "guide/api-keys",
"commandPalette": "guide/command-palette",
"settings": "guide/settings",
"interfaceSettings": "guide/settings#interface",
"appearance": "guide/settings#appearance",
"keybindings": "guide/settings#keybindings",
"users": "guide/users",
"sessions": "guide/users#sessions",
"roles": "guide/roles",
"adminGeneral": "guide/admin-settings",
"branding": "guide/admin-settings#branding",
"adminDatabase": "guide/admin-settings#database",
"adminSsl": "configure/https",
"auditLog": "guide/audit-log",
"importExport": "guide/import-export",
"plugins": "guide/plugins",
"pluginCatalog": "plugins",
"cli": "cli",
"develop": "develop"
}
+17
View File
@@ -0,0 +1,17 @@
import DOCS_PAGES from "./docs-pages.json";
export const DOCS_URL = "https://docs.termix.site";
/**
* Every core docs page the app links to. The docs site checks each one
* exists, so add new links here rather than writing URLs inline.
*/
export type DocsPage = keyof typeof DOCS_PAGES;
/** Full URL of a core docs page. */
export function docsUrl(page: DocsPage = "home"): string {
const path = DOCS_PAGES[page] ?? "";
return path ? `${DOCS_URL}/${path}` : `${DOCS_URL}/`;
}
export { DOCS_PAGES };
+4 -2
View File
@@ -1,3 +1,5 @@
# Do Not Edit Translated Files
# Translations
Only `en.json` is edited by hand. Every other file here is translated through Crowdin and will be overwritten. See the [docs](https://docs.termix.site/translations) to help translate.
Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet.
If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten.
+12 -4
View File
@@ -208,7 +208,8 @@
"mobileSidebarDescription": "Displays the mobile sidebar.",
"toggleSidebar": "Toggle Sidebar",
"search": "Search...",
"new": "New"
"new": "New",
"docs": "Docs"
},
"onboarding": {
"stepCounter": "Step {{current}} of {{total}}",
@@ -252,7 +253,8 @@
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
"desktopLinkTitle": "With my Termix server",
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
"desktopLinked": "Linked to {{server}}"
"desktopLinked": "Linked to {{server}}",
"pluginsDocsLink": "Read about each one"
},
"nav": {
"home": "Home",
@@ -930,7 +932,8 @@
"select": "Select",
"toggleWith": "Toggle with",
"pluginActions": "More actions",
"noTargetTab": "No terminal tab is open"
"noTargetTab": "No terminal tab is open",
"documentation": "Documentation"
},
"splitScreen": {
"title": "Split screen",
@@ -2185,7 +2188,11 @@
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"versionLabel": "{{version}} (beta)",
"sendFeedback": "Send beta feedback"
}
},
"documentation": "Documentation",
"envVars": "Environment variables",
"envRequired": "Required",
"envDefault": "Default: {{value}}"
}
},
"settings": {
@@ -2470,6 +2477,7 @@
"collapse": "Collapse",
"expand": "Expand",
"cannotBeUndone": "This cannot be undone.",
"hostNotFound": "Couldn't find that host. Try refreshing the list.",
"hostCount_one": "{{count}} host",
"hostCount_other": "{{count}} hosts",
"credentialCount_one": "{{count}} credential",
+15
View File
@@ -135,6 +135,8 @@ type ElectronWindow = Window &
export { isElectron };
export const TRUST_TOKEN_KEY = "termixTrustToken";
function getLoggerForService(serviceName: string) {
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
return sshLogger;
@@ -370,6 +372,19 @@ function createApiInstance(
config.headers["Authorization"] = `Bearer ${jwt}`;
}
}
// The desktop app has no cookies with the server, so it carries the
// remember-this-device token itself, and only to login.
const trustToken = localStorage.getItem(TRUST_TOKEN_KEY);
const isLogin =
config.url?.includes("/users/login") ||
/\/users\/auth\/[^/]+\/verify$/.test(config.url ?? "");
if (trustToken && isLogin) {
if (config.headers.set) {
config.headers.set("X-Termix-Trust-Token", trustToken);
} else {
config.headers["X-Termix-Trust-Token"] = trustToken;
}
}
}
if (
+50 -12
View File
@@ -20,6 +20,7 @@ import {
type SSHHostWithStatus,
} from "@/main-axios";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
import { HostEditor } from "@/sidebar/HostEditor";
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
@@ -43,6 +44,7 @@ import {
type ManageMode,
type ManageRequest,
} from "./manage-requests";
import { docsUrl } from "@/lib/docs";
type Editing =
| {
@@ -108,12 +110,20 @@ export function ManageTab() {
const reloadHosts = useCallback(() => {
return getSSHHosts()
.then((raw) => setHosts(raw.map(sshHostToHost)))
.then((raw) => {
const list = raw.map(sshHostToHost);
hostsRef.current = list;
setHosts(list);
})
.catch(() => {});
}, []);
const reloadCredentials = useCallback(() => {
return getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.then((res) => {
const list = mapCredentials(res);
credentialsRef.current = list;
setCredentials(list);
})
.catch(() => {});
}, []);
@@ -206,17 +216,30 @@ export function ManageTab() {
if (!(await confirmDiscard())) return;
await loaded.current;
if (request.kind === "host") {
const host = request.hostId
? (hostsRef.current.find((h) => h.id === request.hostId) ?? null)
: null;
if (request.hostId && !host) return;
const find = () =>
hostsRef.current.find((h) => h.id === request.hostId) ?? null;
let host = request.hostId ? find() : null;
// Our list can lag the sidebar's, so refetch once before giving up.
if (request.hostId && !host) {
invalidateHostsAndStatusCaches();
await reloadHosts();
host = find();
if (!host) {
toast.error(t("manage.hostNotFound"));
return;
}
}
openHost(host, request.draft);
} else if (request.kind === "credential") {
const credential = request.credentialId
? (credentialsRef.current.find(
(c) => c.id === request.credentialId,
) ?? null)
: null;
const find = () =>
credentialsRef.current.find(
(c) => String(c.id) === String(request.credentialId),
) ?? null;
let credential = request.credentialId ? find() : null;
if (request.credentialId && !credential) {
await reloadCredentials();
credential = find();
}
void openCredential(credential);
} else if (request.kind === "defaults") {
void openDefaults(request);
@@ -226,7 +249,15 @@ export function ManageTab() {
setMode(request.mode);
}
},
[confirmDiscard, openHost, openCredential, openDefaults],
[
confirmDiscard,
openHost,
openCredential,
openDefaults,
reloadHosts,
reloadCredentials,
t,
],
);
// The request that opened this tab was parked before it mounted.
@@ -440,6 +471,13 @@ export function ManageTab() {
title={t("nav.manage")}
status={status}
scroll={false}
docs={docsUrl(
mode === "credentials"
? "credentials"
: mode === "hosts"
? "hosts"
: "hostDefaults",
)}
leading={
editing ? (
<span className="flex h-full md:hidden">
+5 -1
View File
@@ -19,6 +19,7 @@ import {
defaultChoices,
recommendedChoices,
} from "../plugin-choices";
import { DocsLink } from "@/components/docs-link";
const CATEGORY_ORDER = [
"Terminal",
@@ -173,7 +174,10 @@ export function PluginsStep({
return (
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
{t("onboarding.pluginsIntro")}
{t("onboarding.pluginsIntro")}{" "}
<DocsLink core="pluginCatalog" className="text-xs">
{t("onboarding.pluginsDocsLink")}
</DocsLink>
</p>
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
+17
View File
@@ -72,6 +72,7 @@ import {
} from "./host-scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { pluginDocsPage, pluginDocsUrl } from "@termix-ssh/plugin-sdk/docs";
import { hasPermission } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
@@ -83,6 +84,20 @@ export interface PluginAppHandle {
dispose: () => void;
}
function createDocsApi(manifest: PluginManifest): TermixApp["docs"] {
const url = pluginDocsUrl(manifest?.docs);
const page = (name?: string, anchor?: string) =>
url ? pluginDocsPage(url, name, anchor) : null;
return {
url,
page,
open(name, anchor) {
const target = page(name, anchor);
if (target) window.open(target, "_blank", "noopener,noreferrer");
},
};
}
/**
* Builds the object a plugin's `activate(app)` receives.
*
@@ -602,6 +617,8 @@ export function createPluginApp(
confirm: (options) => shell.confirm!(options),
docs: createDocsApi(manifest),
desktop: {
available: isElectron(),
remoteServerUrl,
+5
View File
@@ -39,6 +39,7 @@ import {
} from "@/api/open-tabs-api";
import { runKeybindingAction } from "@/shell/keybinding-registry";
import { usePluginScope } from "./scope";
import { usePluginDocsUrl } from "@/components/docs-link";
import { knownPluginIds, usePluginStore } from "./plugin-store";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import type { UiPluginPresets } from "@/types/ui-preferences";
@@ -225,6 +226,10 @@ export const pluginHostBridge: PluginHostBridge = {
return pluginId;
},
useDocsUrl(pluginId, page, anchor) {
return usePluginDocsUrl(pluginId, page, anchor);
},
useTranslation(pluginId) {
const { t, i18n } = useI18nTranslation(pluginId);
return {
+2
View File
@@ -289,6 +289,8 @@ export {
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
// A link to docs. Inside a plugin, page="setup" opens that page of its docs.
export { DocsLink, type DocsLinkProps } from "@/components/docs-link";
export {
AddButton,
FormFooter,
+49
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Bug,
Check,
Database,
@@ -262,6 +263,40 @@ export function PluginDetail({
</div>
</SectionCard>
{plugin.env.length > 0 && (
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
<div className="divide-y divide-border">
{plugin.env.map((v) => (
<div
key={v.name}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs font-semibold">
{v.name}
</code>
{v.required && (
<span className="text-[9px] font-bold uppercase tracking-widest text-accent-brand">
{t("plugins.manager.envRequired")}
</span>
)}
{v.default !== undefined && (
<span className="font-mono text-[10px] text-muted-foreground">
{t("plugins.manager.envDefault", {
value: v.default,
})}
</span>
)}
</div>
<span className="text-[11px] leading-snug text-muted-foreground">
{v.description}
</span>
</div>
))}
</div>
</SectionCard>
)}
<PluginReleaseNotes plugin={plugin} />
{plugin.installed && (
@@ -419,6 +454,20 @@ export function PluginDetail({
</SectionCard>
)}
{plugin.docs && (
<a
href={plugin.docs}
target="_blank"
rel="noreferrer"
className={LINK_ROW}
>
<BookOpen className="size-3.5 shrink-0" />
<span className="truncate">
{t("plugins.manager.documentation")}
</span>
</a>
)}
{hasSettings && (
<button
type="button"
+13 -1
View File
@@ -53,6 +53,8 @@ import {
type SortKey,
} from "./plugin-model";
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
import { docsUrl } from "@/lib/docs";
import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates";
@@ -102,6 +104,7 @@ function PluginsTabBody() {
<PanelShell
icon={<Puzzle className="size-4" />}
title={t("nav.plugins")}
docs={docsUrl("plugins")}
status={t("plugins.manager.status.summary", {
installed: installed.length,
running,
@@ -550,13 +553,21 @@ function InstalledCard({
: t("plugins.manager.enable")}
</Button>
)}
<DocsLink
href={plugin.docs}
variant="icon"
className="ml-auto size-6"
/>
<Button
variant="ghost"
size="icon-xs"
disabled={busy || locked}
title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")}
className="ml-auto text-muted-foreground hover:text-destructive"
className={cn(
"text-muted-foreground hover:text-destructive",
!plugin.docs && "ml-auto",
)}
onClick={() => void manager.uninstall(plugin)}
>
<Trash2 className="size-3" />
@@ -690,6 +701,7 @@ function BrowseCard({
})
: t("plugins.manager.noServerAccess")}
</span>
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
{plugin.installed ? (
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
<Check className="size-3" />
+9
View File
@@ -3,6 +3,7 @@ import {
getCapabilityInfo,
type CapabilityRisk,
} from "@termix-ssh/plugin-sdk/capabilities";
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
import semver from "semver";
import {
CHANGE_TYPES,
@@ -36,6 +37,9 @@ export interface PluginEntry {
repository?: string;
videoId?: string;
features: string[];
/** Docs link: the installed manifest's, else the registry's. */
docs?: string;
env: PluginEnvVar[];
installed: boolean;
version: string | null;
latestVersion: string | null;
@@ -124,6 +128,8 @@ export function mergePlugins(
features: summary.features?.length
? summary.features
: (entry?.features ?? []),
docs: summary.docs ?? entry?.docs,
env: summary.env ?? [],
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
@@ -164,6 +170,8 @@ export function mergePlugins(
repository: entry.repository,
videoId: entry.videoId,
features: entry.features ?? [],
docs: entry.docs,
env: [],
installed: false,
version: null,
latestVersion: entry.latestVersion,
@@ -201,6 +209,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
description: preview.description,
author: preview.author,
features: preview.features ?? [],
env: [],
category: "",
installed: preview.replaces !== null,
version: preview.replaces,
+2
View File
@@ -13,6 +13,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon";
import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
@@ -142,6 +143,7 @@ function HostPluginSection({
<SectionCard
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />}
>
{!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
+10
View File
@@ -40,6 +40,8 @@ import {
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
import { usePluginDocsUrl } from "@/components/docs-link";
import { docsUrl } from "@/lib/docs";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
@@ -195,6 +197,13 @@ export function SettingsTab({
window.setTimeout(attempt, 60);
};
const pluginDocs = usePluginDocsUrl(page.pluginId);
const pageDocs = page.pluginId
? pluginDocs
: page.docs
? docsUrl(page.docs)
: null;
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
@@ -306,6 +315,7 @@ export function SettingsTab({
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more