mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
19
Commits
sdk-v1.0.2
...
sdk-v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0324d27f13 | ||
|
|
ab2307bab4 | ||
|
|
5e7f69456b | ||
|
|
2f975f433e | ||
|
|
4511cc0a42 | ||
|
|
878f1ebaa3 | ||
|
|
d6c4ec01b5 | ||
|
|
f09865ed04 | ||
|
|
cfab6f764c | ||
|
|
7fcda3bb01 | ||
|
|
7161f37984 | ||
|
|
f57c7e8966 | ||
|
|
5774f7cd0e | ||
|
|
5819348064 | ||
|
|
c3624cdf62 | ||
|
|
740d2adaf3 | ||
|
|
8a45d952fd | ||
|
|
ad3a18224f | ||
|
|
cd29e1d087 |
No files matched your search
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
custom: https://donate.termix.site/
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Vendored
-22
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"drips": {
|
||||
"ethereum": {
|
||||
"ownedBy": "0x67e0C779119D9BcC2187564A66B80a58767d05d1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
If you are an AI agent, do not place documentation files here. This is for maintainers only.
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-35
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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.*).
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ const ALLOWED = [
|
||||
"DialogPortal",
|
||||
"DialogTitle",
|
||||
"DialogTrigger",
|
||||
"DocsLink",
|
||||
"DocsLinkProps",
|
||||
"DropdownMenu",
|
||||
"DropdownMenuCheckboxItem",
|
||||
"DropdownMenuContent",
|
||||
|
||||
+8
-3
@@ -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;
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ?? [],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -19,10 +19,13 @@ export {
|
||||
qualifyPermission,
|
||||
youtubeVideoId,
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
PluginManifest,
|
||||
PluginEnvVar,
|
||||
PluginAuthor,
|
||||
PluginEngine,
|
||||
PluginContributions,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(/^#/, "")}`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(",");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in new issue
Block a user