mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
46
Commits
sdk-v1.0.2
...
sdk-v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931761190f | ||
|
|
28fe98e787 | ||
|
|
2ba11652eb | ||
|
|
2e3c242e34 | ||
|
|
4c68d35c21 | ||
|
|
0ce378ec55 | ||
|
|
83cce344e8 | ||
|
|
6f4198cc83 | ||
|
|
abdbb34ce2 | ||
|
|
0b41672a3c | ||
|
|
0f5c2ac862 | ||
|
|
6f7fc05892 | ||
|
|
4eac6d977d | ||
|
|
406766eddd | ||
|
|
f803bfc140 | ||
|
|
b0a0ee3e00 | ||
|
|
af09aa1028 | ||
|
|
45908b5df3 | ||
|
|
b156829ef3 | ||
|
|
399835d8bc | ||
|
|
e1d7b3e7c6 | ||
|
|
970aae4740 | ||
|
|
0324d27f13 | ||
|
|
ab2307bab4 | ||
|
|
5e7f69456b | ||
|
|
2f975f433e | ||
|
|
4511cc0a42 | ||
|
|
878f1ebaa3 | ||
|
|
d6c4ec01b5 | ||
|
|
f09865ed04 | ||
|
|
cfab6f764c | ||
|
|
7fcda3bb01 | ||
|
|
7161f37984 | ||
|
|
f57c7e8966 | ||
|
|
5774f7cd0e | ||
|
|
5819348064 | ||
|
|
c3624cdf62 | ||
|
|
740d2adaf3 | ||
|
|
8a45d952fd | ||
|
|
ad3a18224f | ||
|
|
cd29e1d087 | ||
|
|
4fd9aebee4 | ||
|
|
acc9484280 | ||
|
|
7508a45f36 | ||
|
|
40df00bd95 | ||
|
|
6c342f3957 |
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]
|
||||
}
|
||||
}
|
||||
+21
-44
@@ -5,13 +5,22 @@ yarn-error.log*
|
||||
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
coverage
|
||||
release
|
||||
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# Local data, secrets and editor state
|
||||
db
|
||||
old_db
|
||||
ssl
|
||||
uploads
|
||||
plugins
|
||||
.env
|
||||
.env.*
|
||||
.mcp.json
|
||||
.claude
|
||||
CLAUDE.md
|
||||
termix-plugin-signing.key
|
||||
*.tmxplug.sig
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
@@ -29,63 +38,31 @@ Thumbs.db
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.husky
|
||||
|
||||
README.md
|
||||
CONTRIBUTING.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
|
||||
|
||||
uploads/
|
||||
|
||||
electron/
|
||||
electron-builder.json
|
||||
Casks
|
||||
packaging
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
logs
|
||||
*.log
|
||||
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
jspm_packages/
|
||||
|
||||
.npm
|
||||
|
||||
.eslintcache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
.node_repl_history
|
||||
|
||||
*.tgz
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
.next
|
||||
|
||||
.nuxt
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
.serverless
|
||||
|
||||
.fusebox/
|
||||
|
||||
.dynamodb/
|
||||
|
||||
.tern-port
|
||||
*.tgz
|
||||
.node_repl_history
|
||||
@@ -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)
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
description: "Version to build (e.g., 26.10.0)"
|
||||
required: true
|
||||
build_type:
|
||||
description: "Build type"
|
||||
@@ -22,11 +22,11 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
description: "Version to build (e.g., 26.10.0)"
|
||||
required: true
|
||||
type: string
|
||||
build_type:
|
||||
description: "Build type (Development or Production)"
|
||||
description: "Build type (Development, Beta or Production)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
@@ -52,7 +52,14 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve source revision
|
||||
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
run: |
|
||||
echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
echo "BUILD_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||
|
||||
# The image reads its version from package.json, so a beta has to say so.
|
||||
- name: Stamp beta version
|
||||
if: ${{ inputs.build_type == 'Beta' }}
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version }}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -74,7 +81,7 @@ jobs:
|
||||
ALL_TAGS=()
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("release-$VERSION" "$VERSION" "latest")
|
||||
TAGS+=("$VERSION" "latest")
|
||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||
TAGS+=("beta" "$VERSION")
|
||||
else
|
||||
@@ -120,7 +127,7 @@ jobs:
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
org.opencontainers.image.created=${{ env.BUILD_CREATED }}
|
||||
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
|
||||
|
||||
- name: Login to Docker Hub as termixssh
|
||||
|
||||
@@ -117,6 +117,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
run: |
|
||||
@@ -220,6 +225,11 @@ jobs:
|
||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Build Linux x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -429,6 +439,11 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
@@ -468,14 +483,15 @@ jobs:
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
# Betas never go to the App Store, so they skip the MAS package.
|
||||
- name: Build macOS App Store Package
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
id: mas_build
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
@@ -534,7 +550,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
|
||||
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
|
||||
npm run build
|
||||
fi
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
@@ -555,7 +571,7 @@ jobs:
|
||||
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
if: steps.mas_build.outcome == 'success' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: termix_macos_universal_mas
|
||||
@@ -722,7 +738,7 @@ jobs:
|
||||
choco pack termix-ssh.nuspec
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||
throw "Chocolatey pack failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
- name: Check for Chocolatey API Key
|
||||
@@ -952,8 +968,8 @@ jobs:
|
||||
|
||||
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Verify Cask syntax
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+21
-2
@@ -4,12 +4,31 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Temp
|
||||
- Plugins. Every feature beyond hosts, credentials and sharing is now its own plugin with its own repo, and you pick which ones you want
|
||||
- A Plugins tab to install, update, pin, turn off and remove plugins from the official registry, with release notes and a beta channel per plugin
|
||||
- Developer mode to install a plugin from a file, and a plugin SDK on npm to build your own
|
||||
- The community plugin registry is open for submissions. Termix can install community plugins in a later update
|
||||
- Redesigned UI with a Manage tab for hosts and credentials and a Settings tab with search
|
||||
- New full screen onboarding where you choose which plugins to start with
|
||||
- Beta channel for Termix itself in Settings > Updates
|
||||
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
|
||||
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
|
||||
- Report a bug links that open a prefilled issue in the right repo
|
||||
- `TRUSTED_PROXIES` to choose which proxies nginx takes client IPs from
|
||||
|
||||
### Changed
|
||||
|
||||
- Tem
|
||||
- Termix now uses calendar versions (year, month, patch), starting with 26.10.0
|
||||
- Redesigned host defaults with admin, user and folder levels
|
||||
- Simpler host status: online or offline, kept live without a refresh
|
||||
- Bug reports and feature requests now go to each repo instead of the Support repo
|
||||
|
||||
### Fixed
|
||||
|
||||
- A lot of bugs across the app, the desktop app and every plugin
|
||||
- Several security issues reported through GitHub advisories
|
||||
|
||||
### Removed
|
||||
|
||||
- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading
|
||||
- The donation reminder popup
|
||||
+1
-1
@@ -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.
|
||||
@@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry).
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
|
||||
|
||||
<br />
|
||||
|
||||
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||
<td>Portable · MSI Installer · Chocolatey</td>
|
||||
<td>Portable · EXE and MSI Installer · Chocolatey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></td>
|
||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td>
|
||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||
<td>Apple App Store · DMG · Homebrew</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -149,6 +149,10 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_TUNNEL_HOST: "termix"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -158,8 +162,8 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
@@ -254,4 +258,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
||||
|
||||
## External secret storage
|
||||
|
||||
By default, a single-container installation generates its keys in the Termix
|
||||
data directory for ease of recovery. Production deployments that keep backups
|
||||
or database files outside a trusted encrypted volume should set
|
||||
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
|
||||
secret manager:
|
||||
|
||||
- `JWT_SECRET` (at least 64 characters)
|
||||
- `DATABASE_KEY` (64 hexadecimal characters)
|
||||
- `ENCRYPTION_KEY` (64 hexadecimal characters)
|
||||
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
|
||||
|
||||
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
|
||||
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
|
||||
Hardened mode fails closed instead of writing a replacement key beside the
|
||||
encrypted database.
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"css": "src/ui/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
@@ -15,7 +15,7 @@
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"ui": "@/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
# Stage 1: Install dependencies
|
||||
# The builders only produce JS, so they run on the build machine's own arch
|
||||
# once instead of under QEMU per target. Rolldown hangs under arm64 emulation.
|
||||
FROM --platform=$BUILDPLATFORM node:26-slim AS deps
|
||||
FROM --platform=$BUILDPLATFORM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -37,7 +37,7 @@ COPY . .
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:26-slim AS opkssh-downloader
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
ARG TARGETARCH
|
||||
ARG OPKSSH_VERSION=v0.16.0
|
||||
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
|
||||
echo -n "$OPKSSH_VERSION" > version.txt
|
||||
|
||||
# Stage 5: Production dependencies only
|
||||
FROM node:26-slim AS production-deps
|
||||
FROM node:24-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
FROM node:26-slim
|
||||
FROM node:24-slim
|
||||
WORKDIR /app
|
||||
|
||||
# The release the opkssh-downloader stage baked in, so the plugin trusts that
|
||||
|
||||
+60
-46
@@ -4,152 +4,162 @@
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
|
||||
"sha256": "1725735a058065b211436b166c0ef7d4ca6e74438102764664381160a2b305d3"
|
||||
"sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
|
||||
"sha256": "ea121658305bed9d28e046e096fc458976258d7cb77387e1b8ac82ccb9a001cb"
|
||||
"sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
||||
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
|
||||
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
|
||||
"sha256": "6cb00cf9464e42dbc46786a6ef74b07d62da39ece9905197584d2934265b9b10"
|
||||
"sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
||||
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
|
||||
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
||||
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5",
|
||||
"sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
|
||||
"sha256": "4a774d4a3685b0ee8da5b73d14c60d40f10b6a3f1c5b31a5dfefb94ba30fb7ad"
|
||||
"sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
|
||||
"sha256": "403513bc1f96a7c21d39d55c8fd1d4e55fde7b8e6ec63228839bf3243d93e68a"
|
||||
"sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
||||
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac",
|
||||
"sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
|
||||
"sha256": "2d8cb269242c12522a5195fa44ec14e95ade3b00d7e7be12e9204631518a0f88"
|
||||
"sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
|
||||
"sha256": "5c0a585dcffd1cd523e94d45f2eda32800c403a4c832854fec7331b651da7b7d"
|
||||
"sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
|
||||
"sha256": "7e8a17aa9053954e937ac2274928e29b821dfd671759e356551b27c13e23c9ad"
|
||||
"sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
|
||||
"sha256": "cb6149ab69e348e5a0a1939b31943cb64aded3e1ee930b91dc77c46a715990d0"
|
||||
"sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
||||
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39",
|
||||
"sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
|
||||
"sha256": "b6fbe38cd6d62d0b361c102f13f050ee8a281983dc806e32e8d8feeb416ceb20"
|
||||
"sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
||||
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
|
||||
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
|
||||
"onboarding": {
|
||||
"recommended": "desktop"
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
|
||||
"sha256": "ff0e6948806e0127399d41a6abd0f41fce404e65ba2c9d8044fb6dced401f4bb"
|
||||
"sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
|
||||
"sha256": "4d2e363086a8fe79815bf76b4ed359ac82d5926373ab24854028e4dbc5a57ac6"
|
||||
"sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
||||
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
|
||||
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
||||
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714",
|
||||
"sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
|
||||
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
|
||||
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
"enabledByEnv": ["OIDC_CLIENT_ID"]
|
||||
}
|
||||
},
|
||||
@@ -157,19 +167,19 @@
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
|
||||
"sha256": "997af2068f88c017c76a057683ac5e367566b5b8bc0961fc1e29c32b702a9059"
|
||||
"sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
|
||||
"sha256": "963a84859f8411bcb27e2950d46a7a43b239ace1295ac2abc4a77ecec43806d9"
|
||||
"sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
||||
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
|
||||
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -179,72 +189,76 @@
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
|
||||
"sha256": "00dfe3da65f78d7d085817c93a4e01bfef6931fb70ff5576c76e2f641e337555"
|
||||
"sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
|
||||
"sha256": "bbc508410296ca3434b5ba8c40fa5ef87646d104ec6f75da3bf0e22313bbbf18"
|
||||
"sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
||||
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
|
||||
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
||||
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
|
||||
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
|
||||
"sha256": "d84b0ef1a309bb3a89288f488a242dd19df9e46b237df778c2f2a77e77867960"
|
||||
"sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
|
||||
"sha256": "e680c22279438a566d1033939e4a3063a0fbbeb7f57b6db3bd6712bed09a4388"
|
||||
"sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
|
||||
"sha256": "c974be686488905277a52440b248d14370f3f543a7bd53f3485d5b6db4fab40b"
|
||||
"sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
|
||||
"sha256": "e93d8535617783bc538d415f25a5acc04e3805417002d68d4ebc4044c8fbed8c"
|
||||
"sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
||||
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
|
||||
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
||||
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
|
||||
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 @@
|
||||
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -491,6 +491,13 @@
|
||||
"when": 1791388888997,
|
||||
"tag": "0069_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "5",
|
||||
"when": 1791550497926,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" DROP COLUMN "donation_modal_dismissed";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -491,6 +491,13 @@
|
||||
"when": 1791388888397,
|
||||
"tag": "0069_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1791550496661,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -456,6 +456,13 @@
|
||||
"when": 1791388887785,
|
||||
"tag": "0064_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 65,
|
||||
"version": "6",
|
||||
"when": 1791550495357,
|
||||
"tag": "0065_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"entitlements": "packaging/build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"mergeASARs": false,
|
||||
"singleArchFiles": "**/*.{node,bare,dylib}",
|
||||
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
|
||||
|
||||
+2
-2
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
|
||||
backendCwd: backendDir,
|
||||
};
|
||||
}
|
||||
// fork() does not go through Electron's asar redirector — use the unpacked path.
|
||||
// fork() does not go through Electron's asar redirector, use the unpacked path.
|
||||
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
||||
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
||||
const unpackedRoot = getUnpackedAppRoot(appRoot);
|
||||
@@ -1303,7 +1303,7 @@ function createTray() {
|
||||
try {
|
||||
const { nativeImage } = require("electron");
|
||||
|
||||
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
|
||||
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
|
||||
// use the unpacked path so the OS sees a real file.
|
||||
const publicRoot = isDev
|
||||
? path.join(appRoot, "public")
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"groups": {
|
||||
"server": "Server",
|
||||
"https": "HTTPS",
|
||||
"database": "Database",
|
||||
"secrets": "Secrets",
|
||||
"auth": "Sign in",
|
||||
"proxy": "Proxies and origins",
|
||||
"logging": "Logs and audit",
|
||||
"plugins": "Plugins",
|
||||
"desktop": "Desktop app",
|
||||
"internal": "Internal"
|
||||
},
|
||||
"vars": [
|
||||
{
|
||||
"name": "PORT",
|
||||
"group": "server",
|
||||
"default": "8080",
|
||||
"description": "Port the web UI listens on in the Docker image."
|
||||
},
|
||||
{
|
||||
"name": "DATA_DIR",
|
||||
"group": "server",
|
||||
"default": "/app/data",
|
||||
"description": "Where Termix keeps its database, keys, plugins and uploads. Mount a volume here. Outside Docker the default is ./db/data."
|
||||
},
|
||||
{
|
||||
"name": "ALLOW_EMPTY_DATA_DIR",
|
||||
"group": "server",
|
||||
"default": "false",
|
||||
"description": "Start even when DATA_DIR is empty but an older data folder exists elsewhere. Only set this if you really want a fresh install."
|
||||
},
|
||||
{
|
||||
"name": "PUID",
|
||||
"group": "server",
|
||||
"default": "1000",
|
||||
"docker": true,
|
||||
"description": "User id the container runs as. Match it to the owner of your data volume."
|
||||
},
|
||||
{
|
||||
"name": "PGID",
|
||||
"group": "server",
|
||||
"default": "1000",
|
||||
"docker": true,
|
||||
"description": "Group id the container runs as."
|
||||
},
|
||||
{
|
||||
"name": "BASE_PATH",
|
||||
"group": "server",
|
||||
"description": "Serve Termix under a sub path, like /termix. Set the same path in your reverse proxy."
|
||||
},
|
||||
{
|
||||
"name": "NODE_ENV",
|
||||
"group": "server",
|
||||
"default": "production",
|
||||
"description": "Set by the image. Leave it alone."
|
||||
},
|
||||
{
|
||||
"name": "VERSION",
|
||||
"group": "server",
|
||||
"description": "Set by the image to the Termix version. Leave it alone."
|
||||
},
|
||||
{
|
||||
"name": "SETTINGS_CACHE_REFRESH_SECONDS",
|
||||
"group": "server",
|
||||
"default": "30",
|
||||
"description": "How often each server instance reloads admin settings from the database. 0 turns the refresh off."
|
||||
},
|
||||
{
|
||||
"name": "SSH_AUTH_SOCK",
|
||||
"group": "server",
|
||||
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_SSL",
|
||||
"group": "https",
|
||||
"default": "false",
|
||||
"description": "Serve HTTPS. Termix makes a self-signed certificate on first boot unless you give it one."
|
||||
},
|
||||
{
|
||||
"name": "SSL_PORT",
|
||||
"group": "https",
|
||||
"default": "8443",
|
||||
"description": "Port for HTTPS."
|
||||
},
|
||||
{
|
||||
"name": "SSL_CERT_PATH",
|
||||
"group": "https",
|
||||
"default": "/app/data/ssl/termix.crt",
|
||||
"description": "Certificate file for HTTPS."
|
||||
},
|
||||
{
|
||||
"name": "SSL_KEY_PATH",
|
||||
"group": "https",
|
||||
"default": "/app/data/ssl/termix.key",
|
||||
"description": "Private key file for HTTPS."
|
||||
},
|
||||
{
|
||||
"name": "SSL_DOMAIN",
|
||||
"group": "https",
|
||||
"default": "localhost",
|
||||
"description": "Domain put in the self-signed certificate."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SSL_TERMINATED_BY_NGINX",
|
||||
"group": "https",
|
||||
"internal": true,
|
||||
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DATABASE_DIALECT",
|
||||
"group": "database",
|
||||
"default": "sqlite",
|
||||
"description": "sqlite, postgres or mysql."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_URL",
|
||||
"group": "database",
|
||||
"description": "Connection URL for postgres or mysql, like postgres://user:pass@db:5432/termix."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_POOL_MAX",
|
||||
"group": "database",
|
||||
"default": "10",
|
||||
"description": "Most connections Termix opens to postgres or mysql."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_SSL",
|
||||
"group": "database",
|
||||
"description": "TLS for postgres or mysql: require, no-verify or disable."
|
||||
},
|
||||
{
|
||||
"name": "DB_FILE_ENCRYPTION",
|
||||
"group": "database",
|
||||
"default": "true",
|
||||
"description": "Encrypt the SQLite file at rest. Set false only if something else already encrypts the disk."
|
||||
},
|
||||
{
|
||||
"name": "DB_FILE_KEY",
|
||||
"group": "database",
|
||||
"secret": true,
|
||||
"description": "Only used to open SQLite files encrypted by very old versions. You almost never need it."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP",
|
||||
"group": "database",
|
||||
"default": "false",
|
||||
"description": "Skip the backup Termix takes of the data folder before an upgrade changes the database."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP",
|
||||
"group": "database",
|
||||
"default": "3",
|
||||
"description": "How many of those upgrade backups to keep."
|
||||
},
|
||||
{
|
||||
"name": "DRIZZLE_MIGRATIONS_DIR",
|
||||
"group": "database",
|
||||
"internal": true,
|
||||
"description": "Where database migrations are read from. Set by the image."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "JWT_SECRET",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Signs sign-in sessions. At least 64 characters. Made for you and saved in DATA_DIR/.env if not set. Also read from JWT_SECRET_FILE."
|
||||
},
|
||||
{
|
||||
"name": "DATABASE_KEY",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Key for the encrypted SQLite file, 64 hex characters. Made for you if not set. Also read from DATABASE_KEY_FILE."
|
||||
},
|
||||
{
|
||||
"name": "ENCRYPTION_KEY",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Wraps the data keys that encrypt passwords and SSH keys, 64 hex characters. Made for you if not set. Also read from ENCRYPTION_KEY_FILE."
|
||||
},
|
||||
{
|
||||
"name": "INTERNAL_AUTH_TOKEN",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Token the server uses to call itself. Made for you if not set. Also read from INTERNAL_AUTH_TOKEN_FILE."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_EXTERNAL_SECRETS",
|
||||
"group": "secrets",
|
||||
"default": "false",
|
||||
"description": "Refuse to start unless the four secrets above come from env vars or _FILE files, so none are written to disk."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_SYSTEM_SECRET",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||
},
|
||||
{
|
||||
"name": "WEBAUTHN_SYSTEM_SECRET",
|
||||
"group": "secrets",
|
||||
"secret": true,
|
||||
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ALLOW_PASSWORD_LOGIN",
|
||||
"group": "auth",
|
||||
"description": "true or false. Overrides the admin setting for signing in with a username and password."
|
||||
},
|
||||
{
|
||||
"name": "ALLOW_REGISTRATION",
|
||||
"group": "auth",
|
||||
"description": "true or false. Overrides the admin setting for creating new accounts."
|
||||
},
|
||||
{
|
||||
"name": "ALLOW_PASSWORD_RESET",
|
||||
"group": "auth",
|
||||
"description": "true or false. Overrides the admin setting for resetting a password from the sign in page."
|
||||
},
|
||||
{
|
||||
"name": "EXTERNAL_ALLOW_REGISTRATION",
|
||||
"group": "auth",
|
||||
"description": "true or false. Overrides the admin setting for creating accounts the first time someone signs in with SSO or LDAP."
|
||||
},
|
||||
{
|
||||
"name": "EXTERNAL_FORCE_HTTPS",
|
||||
"group": "auth",
|
||||
"default": "false",
|
||||
"description": "Build sign in callback URLs with https even when the request came in over http. Use it behind a proxy that does not send X-Forwarded-Proto."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_ALLOW_REGISTRATION",
|
||||
"group": "auth",
|
||||
"deprecated": "EXTERNAL_ALLOW_REGISTRATION",
|
||||
"description": "Old name for EXTERNAL_ALLOW_REGISTRATION. Still read in 26.10."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_FORCE_HTTPS",
|
||||
"group": "auth",
|
||||
"deprecated": "EXTERNAL_FORCE_HTTPS",
|
||||
"description": "Old name for EXTERNAL_FORCE_HTTPS. Still read in 26.10."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_ID",
|
||||
"group": "auth",
|
||||
"internal": true,
|
||||
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_ENV_OVERRIDE",
|
||||
"group": "auth",
|
||||
"internal": true,
|
||||
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_AUTH_ENABLED",
|
||||
"group": "auth",
|
||||
"default": "false",
|
||||
"description": "Let a proxy like Authelia or Authentik sign people in by sending their username in a header."
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_AUTH_TRUSTED_PROXIES",
|
||||
"group": "auth",
|
||||
"description": "Comma separated IPs or CIDRs of the proxies allowed to send the headers. Required when trusted proxy login is on."
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_AUTH_USERNAME_HEADER",
|
||||
"group": "auth",
|
||||
"default": "x-forwarded-username",
|
||||
"description": "Header that holds the username."
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_AUTH_ROLE_HEADER",
|
||||
"group": "auth",
|
||||
"default": "x-forwarded-role",
|
||||
"description": "Header that holds the user's groups or roles."
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_AUTH_ROLE_MAP",
|
||||
"group": "auth",
|
||||
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TRUSTED_PROXIES",
|
||||
"group": "proxy",
|
||||
"docker": true,
|
||||
"description": "Comma separated IPs or CIDRs nginx takes the client IP from (X-Forwarded-For). Set it to your reverse proxy."
|
||||
},
|
||||
{
|
||||
"name": "CORS_ALLOWED_ORIGINS",
|
||||
"group": "proxy",
|
||||
"description": "Comma separated extra origins allowed to call the API from a browser."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_ALLOWED_ORIGINS",
|
||||
"group": "proxy",
|
||||
"description": "Comma separated extra origins allowed to open WebSockets."
|
||||
},
|
||||
{
|
||||
"name": "HTTP_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
|
||||
},
|
||||
{
|
||||
"name": "HTTPS_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Proxy for outbound HTTPS requests."
|
||||
},
|
||||
{
|
||||
"name": "NO_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Comma separated hosts that skip the proxy."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LOG_LEVEL",
|
||||
"group": "logging",
|
||||
"default": "info",
|
||||
"description": "debug, info, warn or error."
|
||||
},
|
||||
{
|
||||
"name": "LOG_TIMESTAMP_FORMAT",
|
||||
"group": "logging",
|
||||
"description": "iso or 24h. Unset uses the local time format."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_RETENTION_DAYS",
|
||||
"group": "logging",
|
||||
"description": "Delete audit entries older than this many days. Unset keeps them until the entry cap."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_MAX_ENTRIES",
|
||||
"group": "logging",
|
||||
"default": "10000",
|
||||
"description": "Most audit entries kept. The oldest go first."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_FORWARD_URL",
|
||||
"group": "logging",
|
||||
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
|
||||
},
|
||||
{
|
||||
"name": "AUDIT_LOG_FORWARD_TOKEN",
|
||||
"group": "logging",
|
||||
"secret": true,
|
||||
"description": "Bearer token sent with forwarded audit entries."
|
||||
},
|
||||
{
|
||||
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
||||
"group": "logging",
|
||||
"deprecated": "the session-recording plugin setting",
|
||||
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_REGISTRY_URL",
|
||||
"group": "plugins",
|
||||
"description": "Use another plugin index instead of the official registry."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||
"group": "plugins",
|
||||
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
"description": "Where the plugins shipped with Termix are read from. Set by the image."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_PUBLIC_RATE_LIMIT",
|
||||
"group": "plugins",
|
||||
"default": "120",
|
||||
"description": "Requests per minute one IP may make to a plugin's public routes."
|
||||
},
|
||||
{
|
||||
"name": "PLUGIN_MAX_KV_KEYS",
|
||||
"group": "plugins",
|
||||
"default": "10000",
|
||||
"description": "Most key value entries one plugin may store."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_DEV_RELOAD",
|
||||
"group": "plugins",
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev so plugin rebuilds reload without a restart."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_DEV_RUNNER",
|
||||
"group": "plugins",
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_INSECURE_MODE",
|
||||
"group": "desktop",
|
||||
"default": "false",
|
||||
"description": "Desktop app only. Skip TLS certificate checks for the linked server. Only for testing."
|
||||
},
|
||||
{
|
||||
"name": "ELECTRON_DISABLE_GPU",
|
||||
"group": "desktop",
|
||||
"description": "Desktop app only. Set to 1 to turn off GPU acceleration if the window is blank or flickers."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_LOCAL_SHELL",
|
||||
"group": "desktop",
|
||||
"description": "Desktop app only. Shell the local terminal opens, like /bin/zsh or pwsh.exe."
|
||||
},
|
||||
{
|
||||
"name": "SHELL",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Your login shell, used by the local terminal when TERMIX_LOCAL_SHELL is not set."
|
||||
},
|
||||
{
|
||||
"name": "ELECTRON_EMBEDDED",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app when it runs its own backend."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_DATA_DIR",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by the desktop app to its data folder."
|
||||
},
|
||||
{
|
||||
"name": "XDG_CURRENT_DESKTOP",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Read on Linux to pick the password store and window behavior."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DESKTOP_SESSION",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Read on Linux to pick the password store."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "VITE_BASE_PATH",
|
||||
"group": "internal",
|
||||
"internal": true,
|
||||
"description": "Base path baked in at build time."
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -53,7 +53,7 @@ export default tseslint.config([
|
||||
},
|
||||
{
|
||||
// MySQL has no RETURNING clause, and drizzle's mysql-core does not expose
|
||||
// the method at all — a bare .returning() is a TypeError there, not a bad
|
||||
// the method at all. A bare .returning() is a TypeError there, not a bad
|
||||
// query, and it only fails on the engine no test in this repo runs against.
|
||||
//
|
||||
// 175 call sites were migrated off it. This is what stops number 176.
|
||||
@@ -85,14 +85,14 @@ export default tseslint.config([
|
||||
{
|
||||
// Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON
|
||||
// DUPLICATE KEY and names no columns, so drizzle's mysql-core has no
|
||||
// onConflictDoUpdate at all — another TypeError, not a bad query.
|
||||
// onConflictDoUpdate at all, another TypeError, not a bad query.
|
||||
selector: "CallExpression[callee.property.name='onConflictDoUpdate']",
|
||||
message:
|
||||
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
|
||||
},
|
||||
{
|
||||
// better-sqlite3 puts these on a write result; node-postgres and
|
||||
// mysql2 do not, so reading them directly yields undefined — and
|
||||
// mysql2 do not, so reading them directly yields undefined, and
|
||||
// Number(undefined) is NaN, which reaches the database as the string
|
||||
// "NaN" and fails an integer column. Three call sites did exactly
|
||||
// this and only broke on Postgres.
|
||||
|
||||
-29
@@ -20,35 +20,6 @@
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Termix</title>
|
||||
<style>
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skinny-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #1e1e21;
|
||||
}
|
||||
|
||||
.toolbar-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a transparent;
|
||||
|
||||
@@ -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
+14
-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.0.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@tailwindcss/node": "^4.2.4",
|
||||
"@tailwindcss/oxide": "^4.2.4",
|
||||
"esbuild": "^0.28.1",
|
||||
@@ -15087,9 +15054,21 @@
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
},
|
||||
"i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
},
|
||||
"ssh2": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
+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,36 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Fixed
|
||||
|
||||
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
|
||||
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
|
||||
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
|
||||
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
|
||||
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
|
||||
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- `termix-plugin build` puts a plugin's own Tailwind classes in core's utilities layer, so responsive, hover and group variants and `className` overrides on SDK components work. Plain classes core already ships stay below core's utilities as before, while variants core ships go with the plugin's own so `max-h-56 md:max-h-none` keeps its order. Rebuild a plugin to pick it up
|
||||
|
||||
## 1.0.3
|
||||
|
||||
Plugin API 1.2.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `docs` manifest field: an https link to the plugin's docs. Termix shows it as a Documentation link on the plugin's page and in its settings
|
||||
- `env` manifest field: the environment variables the plugin reads, listed in its docs and on its page
|
||||
- `app.docs` and `useDocsUrl()` for links into the plugin's own docs, and `DocsLink` in `@termix-ssh/plugin-sdk/ui`
|
||||
- `PanelShell` takes a `docs` link and shows it as a book icon in the header
|
||||
- `@termix-ssh/plugin-sdk/openapi` with `buildOpenApi()`, and `termix-plugin openapi`, which writes `dist/openapi.json` from the `@openapi` blocks in `src/backend`. `build` runs it too
|
||||
- `@termix-ssh/plugin-sdk/docs` with `pluginDocsUrl()`, `pluginDocsPage()` and `pluginEnvVars()`
|
||||
- `termix-plugin validate` warns when `docs/index.md` is missing or the source reads an env var the manifest does not list
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -28,8 +28,10 @@ npm install --save-dev @termix-ssh/plugin-sdk
|
||||
|
||||
```bash
|
||||
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
||||
npx termix-plugin patch # apply the plugin's dependency patches (build does this too)
|
||||
npx termix-plugin test # run the plugin's Vitest suite
|
||||
npx termix-plugin validate # check manifest.json and the files it names
|
||||
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
|
||||
npx termix-plugin migrations # write migrations from the table definitions
|
||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
||||
@@ -39,7 +41,7 @@ npx termix-plugin keygen --out <dir> # make a new signing key pair
|
||||
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
|
||||
```
|
||||
|
||||
`dev` needs developer mode on (Admin settings, Plugins) and an API key from an
|
||||
`dev` needs developer mode on (Settings, General) and an API key from an
|
||||
admin account. It installs the plugin from a file, marked unverified, and
|
||||
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
|
||||
in place of the flags. Reload the page to pick up frontend changes.
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
import { openapi } from "./openapi.mjs";
|
||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||
|
||||
const BACKEND_ENTRIES = [
|
||||
"src/backend/index.ts",
|
||||
@@ -96,14 +98,36 @@ export function applyPatches(cwd, pluginId) {
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CSS = path.join(
|
||||
const HOST_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"host",
|
||||
"theme.css",
|
||||
);
|
||||
const THEME_CSS = path.join(HOST_DIR, "theme.css");
|
||||
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
|
||||
|
||||
/**
|
||||
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
||||
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
|
||||
* because the plugin's file loaded later. Only classes core also ships go
|
||||
* there; `utilities: false` keeps the rest in core's own utilities layer.
|
||||
*/
|
||||
export function lowerPluginLayers(css, { utilities = true } = {}) {
|
||||
const layers = utilities ? "properties|utilities" : "properties";
|
||||
return css.replace(
|
||||
new RegExp(`@layer (${layers})(?=[\\s{;,])`, "g"),
|
||||
"@layer termix-plugin-$1",
|
||||
);
|
||||
}
|
||||
|
||||
/** Classes core's CSS has rules for, or null with an SDK built before 1.0.4. */
|
||||
function readCoreClasses() {
|
||||
if (!fs.existsSync(CORE_CLASSES)) return null;
|
||||
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
@@ -112,19 +136,6 @@ const THEME_CSS = path.join(
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
/**
|
||||
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
||||
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
|
||||
* because the plugin's file loaded later.
|
||||
*/
|
||||
export function lowerPluginLayers(css) {
|
||||
return css.replace(
|
||||
/@layer (properties|utilities)(?=[\s{;,])/g,
|
||||
"@layer termix-plugin-$1",
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
@@ -136,10 +147,9 @@ export async function buildTailwind(cwd, outDir) {
|
||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||
].join("\n");
|
||||
const compiler = await compile(input, {
|
||||
base: path.dirname(THEME_CSS),
|
||||
onDependency: () => {},
|
||||
});
|
||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||
const compiler = () =>
|
||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
@@ -151,9 +161,23 @@ export async function buildTailwind(cwd, outDir) {
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const css = lowerPluginLayers(
|
||||
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
|
||||
);
|
||||
const emit = async (candidates, options) =>
|
||||
candidates.length === 0
|
||||
? ""
|
||||
: lowerPluginLayers(
|
||||
optimize((await compiler()).build(candidates), {
|
||||
minify: true,
|
||||
}).code.trim(),
|
||||
options,
|
||||
);
|
||||
const candidates = scanner.scan();
|
||||
const coreClasses = readCoreClasses();
|
||||
const { shared, own } = coreClasses
|
||||
? splitByCoreClasses(candidates, coreClasses)
|
||||
: { shared: candidates, own: [] };
|
||||
const css = [await emit(shared), await emit(own, { utilities: false })]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!css) return;
|
||||
const target = path.join(outDir, "frontend.css");
|
||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
||||
@@ -241,6 +265,7 @@ export async function build({ cwd }) {
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
await openapi({ cwd, quiet: true });
|
||||
|
||||
console.log(`built ${pluginId}`);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function resolveDevOptions(args, env = process.env) {
|
||||
|
||||
const HINTS = {
|
||||
DEVELOPER_MODE_OFF:
|
||||
"Turn on developer mode in Admin settings, under Plugins, then save again.",
|
||||
"Turn on Plugin developer mode in Settings, General, then save again.",
|
||||
SIGNED_ONLY:
|
||||
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
|
||||
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
|
||||
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
|
||||
external: BACKEND_EXTERNALS,
|
||||
});
|
||||
|
||||
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
|
||||
const module = await import(pathToFileURL(outfile).href);
|
||||
const tables = module.tables ?? module.default;
|
||||
if (!Array.isArray(tables)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { loadSdkModule } from "./validate.mjs";
|
||||
|
||||
/**
|
||||
* Writes dist/openapi.json from the @openapi blocks in src/backend. A plugin
|
||||
* with no documented routes gets no file.
|
||||
*/
|
||||
export async function openapi({ cwd, quiet = false }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const backendDir = path.join(cwd, "src", "backend");
|
||||
if (!fs.existsSync(backendDir)) return null;
|
||||
|
||||
const { buildOpenApi, hasOpenApiPaths } = await loadSdkModule("openapi");
|
||||
const spec = await buildOpenApi({
|
||||
title: `${manifest.name ?? pluginId} API`,
|
||||
version: manifest.version ?? "0.0.0",
|
||||
description: manifest.description,
|
||||
files: [path.join(backendDir, "**", "*.{ts,tsx,js,mjs}")],
|
||||
});
|
||||
|
||||
const target = path.join(cwd, "dist", "openapi.json");
|
||||
if (!hasOpenApiPaths(spec)) {
|
||||
fs.rmSync(target, { force: true });
|
||||
if (!quiet) console.log(`${pluginId}: no documented routes`);
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(spec, null, 2)}\n`);
|
||||
if (!quiet) {
|
||||
console.log(
|
||||
`wrote dist/openapi.json (${Object.keys(spec.paths).length} paths)`,
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -41,6 +41,10 @@ export async function validate({ cwd }) {
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
problems.push(...(await validateChangelogFile(cwd, raw)));
|
||||
|
||||
for (const warning of docsWarnings(cwd, raw)) {
|
||||
console.warn(` warn ${warning}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
|
||||
@@ -49,6 +53,69 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
"BASE_PATH",
|
||||
"VERSION",
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"PATH",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"ELECTRON_EMBEDDED",
|
||||
]);
|
||||
|
||||
const ENV_READS = [
|
||||
/process\.env\.([A-Z][A-Z0-9_]*)/g,
|
||||
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
|
||||
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||
];
|
||||
|
||||
function walkSources(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkSources(full, out);
|
||||
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs gaps that do not block a release: no docs/index.md, or a process.env
|
||||
* read the manifest does not list in env.
|
||||
*/
|
||||
export function docsWarnings(cwd, raw) {
|
||||
const warnings = [];
|
||||
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
|
||||
warnings.push("docs/index.md is missing, so the plugin has no docs page");
|
||||
}
|
||||
const declared = new Set(
|
||||
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
|
||||
);
|
||||
const missing = new Set();
|
||||
for (const file of walkSources(path.join(cwd, "src"))) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const pattern of ENV_READS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const name = match[1];
|
||||
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of [...missing].sort()) {
|
||||
warnings.push(`reads ${name} but manifest env does not list it`);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json has to agree with the manifest: the same version, and a real
|
||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||
@@ -151,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
|
||||
const present = DIALECTS.filter((dialect) =>
|
||||
fs.existsSync(path.join(root, dialect)),
|
||||
);
|
||||
for (const dialect of DIALECTS) {
|
||||
if (present.length > 0 && !present.includes(dialect)) {
|
||||
problems.push(`migrations/${dialect} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const byDialect = new Map();
|
||||
for (const dialect of present) {
|
||||
|
||||
@@ -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.5",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -53,6 +53,14 @@
|
||||
"types": "./dist/changelog.d.ts",
|
||||
"default": "./dist/changelog.js"
|
||||
},
|
||||
"./docs": {
|
||||
"types": "./dist/docs.d.ts",
|
||||
"default": "./dist/docs.js"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./dist/openapi.js"
|
||||
},
|
||||
"./settings": {
|
||||
"types": "./dist/settings.d.ts",
|
||||
"default": "./dist/settings.js"
|
||||
@@ -102,6 +110,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@tailwindcss/node": "^4.2.4",
|
||||
"@tailwindcss/oxide": "^4.2.4",
|
||||
"esbuild": "^0.28.1",
|
||||
@@ -139,6 +148,18 @@
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"optional": true
|
||||
},
|
||||
"i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -85,6 +85,27 @@
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"description": "Short lines listing what the plugin does, shown on its page."
|
||||
},
|
||||
"docs": {
|
||||
"type": "string",
|
||||
"pattern": "^https://",
|
||||
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"description": "Environment variables the plugin reads, listed in its docs.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||
"default": { "type": "string" },
|
||||
"required": { "type": "boolean" },
|
||||
"secret": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -194,7 +194,9 @@ export function parseChangelog(markdown: string): {
|
||||
|
||||
/**
|
||||
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
||||
* be that version, so a release never ships without its notes.
|
||||
* be that version, so a release never ships without its notes. A beta like
|
||||
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
|
||||
* to be 1.1.0 or older.
|
||||
*/
|
||||
export function validateChangelog(
|
||||
markdown: string,
|
||||
@@ -206,7 +208,16 @@ export function validateChangelog(
|
||||
return problems;
|
||||
}
|
||||
const newest = changelog.releases[0].version;
|
||||
if (manifestVersion && newest !== manifestVersion) {
|
||||
const base = manifestVersion
|
||||
? semver.valid(semver.coerce(manifestVersion))
|
||||
: null;
|
||||
const betaOk =
|
||||
!!manifestVersion &&
|
||||
!!semver.prerelease(manifestVersion) &&
|
||||
!!base &&
|
||||
!!semver.valid(newest) &&
|
||||
semver.lte(newest, base);
|
||||
if (manifestVersion && newest !== manifestVersion && !betaOk) {
|
||||
problems.push(
|
||||
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
|
||||
export const id = () =>
|
||||
builder({ type: "id", primaryKey: true, notNull: true });
|
||||
|
||||
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
|
||||
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
|
||||
export const text = () => builder({ type: "text" });
|
||||
|
||||
/** Bounded string. Indexable on every engine. */
|
||||
|
||||
@@ -80,11 +80,13 @@ function columnType(
|
||||
// though it was declared unbounded. defineTable refuses to index a
|
||||
// text/json column, so in practice this only catches a primary key.
|
||||
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
|
||||
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
|
||||
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
/** Column types that are TEXT on MySQL when nothing keys them. */
|
||||
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
|
||||
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
|
||||
"text",
|
||||
"json",
|
||||
|
||||
@@ -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.*).
|
||||
|
||||
@@ -32,7 +32,7 @@ export function execCommand(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
|
||||
@@ -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>>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -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;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* drizzle/mysql. Nothing imports them to run a query.
|
||||
*
|
||||
* That is not an oversight. The query builder needs two things from a table
|
||||
* object — the identifiers to interpolate, and the encoders that turn JS values
|
||||
* into driver values — and the sqlite definitions supply both correctly for
|
||||
* object: the identifiers to interpolate, and the encoders that turn JS values
|
||||
* into driver values. The sqlite definitions supply both correctly for
|
||||
* every engine, which is why all 44 repositories import schema.ts directly:
|
||||
*
|
||||
* - text and integer encode as themselves everywhere
|
||||
@@ -17,15 +17,15 @@
|
||||
* which is true for JS `true` as well as for 1
|
||||
* - real is a plain number on all three
|
||||
*
|
||||
* What genuinely differs between the dialects is DDL — column types, key
|
||||
* lengths, autoincrement syntax — and DDL is exactly what these files exist to
|
||||
* What genuinely differs between the dialects is DDL (column types, key
|
||||
* lengths, autoincrement syntax), and DDL is exactly what these files exist to
|
||||
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips
|
||||
* above against real servers rather than trusting this comment.
|
||||
*
|
||||
* The schema is declared once, in sqlite-core, and the other two dialects are
|
||||
* derived. Hand-maintaining three copies of 52 tables would mean a renamed
|
||||
* table has to land in three places consistently or a foreign key silently
|
||||
* points at the wrong one — and the schema is regular enough that the mapping
|
||||
* points at the wrong one, and the schema is regular enough that the mapping
|
||||
* is mechanical.
|
||||
*
|
||||
* What varies between dialects is small and closed:
|
||||
@@ -198,7 +198,7 @@ function transform(source, dialect) {
|
||||
);
|
||||
|
||||
// Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts
|
||||
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT
|
||||
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column. On a TEXT
|
||||
// one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an
|
||||
// expression default works on any type, and an expression is written
|
||||
// parenthesised. MariaDB accepts the bare form, which is why this only
|
||||
@@ -272,13 +272,13 @@ function transform(source, dialect) {
|
||||
}
|
||||
|
||||
function header(dialect) {
|
||||
return `// GENERATED FILE — do not edit.
|
||||
return `// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
|
||||
// Target dialect: ${dialect}.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("mysql output", () => {
|
||||
expect(out).toContain('double("score")');
|
||||
});
|
||||
|
||||
it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => {
|
||||
it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
|
||||
expect(out).toContain('varchar("user_id", { length: 255 })');
|
||||
expect(out).toContain('text("name")');
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ await sharp(svgBuffer)
|
||||
.toFile(join(publicDir, "full-icon.png"));
|
||||
console.log(" ✓ full-icon.png");
|
||||
|
||||
// favicon.ico — embed 16, 32, 48 px layers
|
||||
// favicon.ico: embed 16, 32, 48 px layers
|
||||
console.log("Generating favicon.ico...");
|
||||
const icoSizes = [16, 32, 48];
|
||||
const icoBuffers = await Promise.all(
|
||||
@@ -55,7 +55,7 @@ const icoBuffers = await Promise.all(
|
||||
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
|
||||
console.log(" ✓ favicon.ico");
|
||||
|
||||
// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers
|
||||
// icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
|
||||
console.log("Generating icon.ico...");
|
||||
const winSizes = [16, 32, 48, 64, 128, 256];
|
||||
const winBuffers = await Promise.all(
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
@@ -28,7 +28,7 @@ function fail(message) {
|
||||
function buildTable(version, mobileVersion, mobileTag) {
|
||||
const tag = `v${version}`;
|
||||
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `v${mobileVersion}`}`;
|
||||
|
||||
const win = (file) => `${base}/${file}`;
|
||||
const linux = (file) => `${base}/${file}`;
|
||||
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
|
||||
return [
|
||||
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
||||
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`,
|
||||
`| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
|
||||
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("buildBody", () => {
|
||||
expect(body).not.toContain("A bug");
|
||||
expect(body).toContain("/releases/download/v26.10.0/termix_");
|
||||
expect(body).toContain(
|
||||
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk",
|
||||
"Mobile/releases/download/v1.2.0/termix_android.apk",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -131,6 +131,17 @@ describe("validateChangelog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a beta through when its notes are not written yet", () => {
|
||||
expect(validateChangelog(FILE, "1.1.0-beta.1")).toEqual([]);
|
||||
expect(validateChangelog(FILE, "1.2.0-beta.3")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a beta older than the newest release", () => {
|
||||
expect(validateChangelog(FILE, "1.0.1-beta.1")).toContain(
|
||||
"the newest release is 1.1.0, but the manifest version is 1.0.1-beta.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a file with no releases", () => {
|
||||
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
|
||||
'no releases found, add a "## 1.0.0" heading',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { validate } from "../packages/plugin-sdk/cli/commands/validate.mjs";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const root of roots.splice(0))
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plugin(dialects: string[]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-validate-"));
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({ id: "fixture", version: "1.0.0" }),
|
||||
);
|
||||
for (const dialect of dialects) {
|
||||
const dir = path.join(root, "migrations", dialect);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "0001_init.sql"),
|
||||
"CREATE TABLE p_fixture_items (id INTEGER PRIMARY KEY);\n",
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
async function problems(cwd: string) {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line) => lines.push(line));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await expect(validate({ cwd })).rejects.toThrow();
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("termix-plugin validate migrations", () => {
|
||||
it("flags an engine with no migrations folder", async () => {
|
||||
const output = await problems(plugin(["sqlite", "postgres"]));
|
||||
expect(output).toContain("migrations/mysql is missing");
|
||||
expect(output).not.toContain("migrations/postgres is missing");
|
||||
});
|
||||
|
||||
it("accepts all three engines", async () => {
|
||||
const output = await problems(plugin(["sqlite", "postgres", "mysql"]));
|
||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
* Runs the repository layer against a real Postgres or MySQL server.
|
||||
*
|
||||
* The unit tests only ever see SQLite, so the parts of this codebase that
|
||||
* differ per engine — the RETURNING replacements, the read-then-write
|
||||
* transactions, the value encoders — have no coverage there at all. This is
|
||||
* differ per engine (the RETURNING replacements, the read-then-write
|
||||
* transactions, the value encoders) have no coverage there at all. This is
|
||||
* what covers them, and it needs a live server, which is why it is a script
|
||||
* rather than a test.
|
||||
*
|
||||
@@ -12,7 +12,7 @@
|
||||
* npm run verify:dialect -- mysql://user:pass@host:3306/db
|
||||
*
|
||||
* Applies the migrations first, through the same runRemoteMigrations() the
|
||||
* application uses at startup — so a broken migration fails here rather than in
|
||||
* application uses at startup, so a broken migration fails here rather than in
|
||||
* production. Writes real rows: point it at a scratch database.
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,7 @@ const { drizzle } = await import(
|
||||
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
|
||||
);
|
||||
|
||||
// No schema option on purpose — see connect.ts.
|
||||
// No schema option on purpose, see connect.ts.
|
||||
const db = drizzle(url);
|
||||
const context = { dialect, drizzle: db };
|
||||
|
||||
@@ -87,7 +87,7 @@ check("insert returns the stored row", created?.username, "before");
|
||||
check("boolean true survives the round trip", created?.isAdmin, true);
|
||||
|
||||
// updateReturning must report the state AFTER the write. Reading first would
|
||||
// return the value the update replaced — silently, with no error.
|
||||
// return the value the update replaced, silently, with no error.
|
||||
const updated = await users.update(userId, { username: "after" });
|
||||
check("update returns the new value", updated?.username, "after");
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -151,6 +171,27 @@ async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
|
||||
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
||||
|
||||
/**
|
||||
* An account that only signs in through SSO or LDAP must keep doing so. A
|
||||
* passkey or any other local method would skip the identity provider, so a
|
||||
* user disabled there could still get in.
|
||||
*/
|
||||
export function assertMethodAllowedFor(
|
||||
user: Pick<UserRecord, "isExternal" | "passwordHash">,
|
||||
identity: VerifiedIdentity,
|
||||
methodId: string,
|
||||
): void {
|
||||
if (identity.kind === "external" || !user.isExternal) return;
|
||||
if (user.passwordHash && user.passwordHash.trim() !== "") return;
|
||||
ensureCoreLoginProviders();
|
||||
if (getLoginMethod(methodId)?.external) return;
|
||||
throw new LoginMethodError(
|
||||
"This account signs in through its identity provider",
|
||||
403,
|
||||
"external_login_required",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveUser(
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
@@ -233,6 +274,7 @@ export async function runLogin(
|
||||
): Promise<LoginResult> {
|
||||
const deviceType = parseUserAgent(req).type;
|
||||
const user = await resolveUser(identity, deviceType);
|
||||
assertMethodAllowedFor(user, identity, context.methodId);
|
||||
|
||||
await unlockUser(user, identity, deviceType);
|
||||
if ("password" in identity && identity.password) {
|
||||
@@ -473,22 +515,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 +547,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(
|
||||
@@ -274,7 +268,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
* localVersion:
|
||||
* type: string
|
||||
* description: Version of the running instance. Use this field for client compatibility checks.
|
||||
* example: 2.7.1
|
||||
* example: 26.10.0
|
||||
* status:
|
||||
* type: string
|
||||
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
|
||||
@@ -286,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
* remoteVersion:
|
||||
* type: string
|
||||
* description: Newest release version on the channel. Present only when the update check succeeds.
|
||||
* example: 2.8.0
|
||||
* example: 26.10.0
|
||||
* version:
|
||||
* type: string
|
||||
* deprecated: true
|
||||
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
|
||||
* example: 2.8.0
|
||||
* example: 26.10.0
|
||||
* latest_release:
|
||||
* type: object
|
||||
* description: GitHub release metadata. Present only when the update check succeeds.
|
||||
@@ -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();
|
||||
|
||||
@@ -50,7 +50,7 @@ export function sslOption(
|
||||
/**
|
||||
* Opens a connection to a client-server engine.
|
||||
*
|
||||
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where
|
||||
* SQLite is not handled here, it has its own lifecycle in db/index.ts, where
|
||||
* the database is decrypted into memory and serialised back to a file. This
|
||||
* covers the engines that connect to something already running.
|
||||
*
|
||||
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
|
||||
|
||||
// No `schema` option: it only feeds drizzle's relational query API
|
||||
// (`db.query.*`), which nothing here uses. The query builder takes its table
|
||||
// names and value encoders from the table objects the repositories import —
|
||||
// names and value encoders from the table objects the repositories import,
|
||||
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
|
||||
if (dialect === "postgres") {
|
||||
const { drizzle } = await import("drizzle-orm/node-postgres");
|
||||
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
|
||||
}
|
||||
|
||||
// mysql2 names the pool limit differently and wants no `ssl` key at all when
|
||||
// TLS is off — passing false is not the same as omitting it.
|
||||
// TLS is off, passing false is not the same as omitting it.
|
||||
const { drizzle } = await import("drizzle-orm/mysql2");
|
||||
return drizzle({
|
||||
connection: {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
|
||||
*
|
||||
* SQLite here is an in-memory database serialised back to an encrypted file, so
|
||||
* every write needs a trigger to flush it. Client-server engines have already
|
||||
* durably committed by the time the query returns — there is no file to write
|
||||
* durably committed by the time the query returns, there is no file to write
|
||||
* and nothing to schedule.
|
||||
*/
|
||||
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
|
||||
|
||||
@@ -242,8 +242,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -970,9 +969,7 @@ const migrateSchema = () => {
|
||||
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
|
||||
addColumnIfNotExists("users", "registered_at", "TEXT");
|
||||
if (!hadRegisteredAtColumn) {
|
||||
// Pre-existing users are backdated past the 30 day mark so they see the
|
||||
// donation modal immediately on upgrade instead of waiting a fresh
|
||||
// 30 days as if they had just registered.
|
||||
// Pre-existing users get a date in the past, since their real one is unknown.
|
||||
try {
|
||||
sqlite.exec(
|
||||
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
|
||||
@@ -1000,12 +997,6 @@ const migrateSchema = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
addColumnIfNotExists(
|
||||
"users",
|
||||
"donation_modal_dismissed",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
@@ -1103,7 +1094,7 @@ const migrateSchema = () => {
|
||||
// Derive the replacement table from the live definition instead of
|
||||
// restating it here. The table keeps gaining columns (cert_public_key,
|
||||
// pin, sort_order, sync_id, ...), and a second copy of the column list
|
||||
// falls behind every time one is added — leaving the copy narrower than
|
||||
// falls behind every time one is added, leaving the copy narrower than
|
||||
// the table, so the INSERT below fails and the constraint stays put.
|
||||
const createSql = sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
|
||||
@@ -2108,7 +2099,7 @@ export function getSqlite(): Database.Database {
|
||||
if (dialect !== "sqlite") {
|
||||
throw new Error(
|
||||
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
|
||||
`synchronous query, which only SQLite offers — give it an async path instead.`,
|
||||
`synchronous query, which only SQLite offers. Give it an async path instead.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
|
||||
@@ -25,7 +25,7 @@ export function migrationsFolder(
|
||||
* Brings a remote database up to the current schema.
|
||||
*
|
||||
* drizzle's migrator records what it has applied in its own table, so this is
|
||||
* safe to run on every start — including against a database another instance
|
||||
* safe to run on every start, including against a database another instance
|
||||
* already migrated.
|
||||
*/
|
||||
export async function runRemoteMigrations(
|
||||
|
||||
@@ -77,6 +77,7 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
"donation_modal_dismissed",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
user_preferences: [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// GENERATED FILE — do not edit.
|
||||
// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
|
||||
// Target dialect: mysql.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
|
||||
import {
|
||||
@@ -27,9 +27,6 @@ export const users = mysqlTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = mysqlTable("settings", {
|
||||
@@ -542,7 +539,7 @@ export const sharedHostSecrets = mysqlTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -592,7 +589,7 @@ export const userRoles = mysqlTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1060,7 +1057,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = mysqlTable(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// GENERATED FILE — do not edit.
|
||||
// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
|
||||
// Target dialect: postgres.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
|
||||
import {
|
||||
@@ -27,9 +27,6 @@ export const users = pgTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
@@ -542,7 +539,7 @@ export const sharedHostSecrets = pgTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -592,7 +589,7 @@ export const userRoles = pgTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1060,7 +1057,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = pgTable(
|
||||
|
||||
@@ -15,11 +15,6 @@ export const users = sqliteTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
@@ -534,7 +529,7 @@ export const sharedHostSecrets = sqliteTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -584,7 +579,7 @@ export const userRoles = sqliteTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1058,7 +1053,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = sqliteTable(
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AuditLogRepository {
|
||||
/**
|
||||
* Cached row count backing the cap check. Static because the factory builds
|
||||
* a repository per call, so a per-instance count would never survive to be
|
||||
* reused. Null means "unknown, re-read" — which is also how any path that
|
||||
* reused. Null means "unknown, re-read", which is also how any path that
|
||||
* deletes rows invalidates it.
|
||||
*/
|
||||
private static cachedCount: number | null = null;
|
||||
@@ -233,13 +233,13 @@ export class AuditLogRepository {
|
||||
*
|
||||
* Both passes used to run inline on every insert: a retention DELETE plus a
|
||||
* COUNT over the whole table, thousands of times an hour on a busy install,
|
||||
* almost always to find nothing to do — and audit writes sit in the request
|
||||
* almost always to find nothing to do, and audit writes sit in the request
|
||||
* path of the actions they record.
|
||||
*
|
||||
* They are split by what they cost and what they guarantee. Retention is
|
||||
* time-based, so nothing is lost by checking it on an interval. The row cap
|
||||
* is a disk-space guard that has to react to inserts, so it is still checked
|
||||
* on the write that crosses it — but against a cached count, so the common
|
||||
* on the write that crosses it, but against a cached count, so the common
|
||||
* case is an integer compare rather than a COUNT.
|
||||
*/
|
||||
private async pruneIfDue(): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import type * as schema from "../db/schema.js";
|
||||
|
||||
// Re-exported so repositories can keep importing it from here, but defined in
|
||||
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
|
||||
// db/dialect.ts, a local copy that said "sqlite" survived here for a while and
|
||||
// typed every context as SQLite-only while the runtime already carried all
|
||||
// three, which silently made the dialect branches unreachable to the checker.
|
||||
export type { DatabaseDialect } from "../db/dialect.js";
|
||||
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
|
||||
*
|
||||
* This is a deliberate approximation, not an accident. The query-builder
|
||||
* surface the repositories actually use is the same on all three engines, and
|
||||
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
|
||||
* that equivalence is asserted in multi-dialect.test.ts rather than assumed,
|
||||
* identifier quoting, placeholder style and value coercion are all covered
|
||||
* there. At runtime this may hold a Postgres or MySQL instance.
|
||||
*
|
||||
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
|
||||
*
|
||||
* Deliberately drizzle-only: with no raw driver handle here, no repository can
|
||||
* reach for engine-specific SQL. Retention queries that previously needed
|
||||
* `datetime('now', ?)` compute their cutoff in JS instead — see
|
||||
* `datetime('now', ?)` compute their cutoff in JS instead, see
|
||||
* ./sql-timestamp.ts.
|
||||
*/
|
||||
export interface DatabaseContext {
|
||||
|
||||
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
|
||||
* write.
|
||||
*
|
||||
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
|
||||
* exercises this function — see tests/database/repositories/factory-context.
|
||||
* exercises this function, see tests/database/repositories/factory-context.
|
||||
*/
|
||||
export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
return {
|
||||
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
* Only meaningful for SQLite, where the database lives in memory and has to be
|
||||
* serialised back to its encrypted file. On Postgres and MySQL the write is
|
||||
* already durable, so no hook is installed at all rather than one that does
|
||||
* nothing — repositories call it as `this.onWrite?.()`.
|
||||
* nothing, repositories call it as `this.onWrite?.()`.
|
||||
*/
|
||||
export function createCurrentRepositoryWriteHook(
|
||||
reason: string,
|
||||
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw driver handle for the few synchronous call sites that cannot await —
|
||||
* Raw driver handle for the few synchronous call sites that cannot await,
|
||||
* getCurrentSettingValue below, and settings reads during startup. Repositories
|
||||
* must not use this: they take a DatabaseContext, which is drizzle-only.
|
||||
* Porting to another engine means giving these callers an async path first.
|
||||
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
|
||||
*
|
||||
* The cache is per-process and updated in the process that writes. That is
|
||||
* enough for SQLite, where there is only ever one process. On Postgres and
|
||||
* MySQL — which exist here precisely so more than one instance can share the
|
||||
* data — a setting changed on one replica would otherwise never reach the
|
||||
* MySQL, which exist here precisely so more than one instance can share the
|
||||
* data, a setting changed on one replica would otherwise never reach the
|
||||
* others, because the synchronous read has no way to go back to the database.
|
||||
*
|
||||
* Periodic re-priming does not make the value immediately consistent. It bounds
|
||||
|
||||
@@ -31,7 +31,7 @@ export class HostFolderRepository {
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RenameFolderResult> {
|
||||
// CAST target: every engine spells the text type differently enough to
|
||||
// matter here — MySQL has no `text` cast and wants `char`.
|
||||
// matter here, MySQL has no `text` cast and wants `char`.
|
||||
const textType = this.context.dialect === "mysql" ? "char" : "text";
|
||||
const oldPrefix = `${oldName} / `;
|
||||
const newPrefix = `${newName} / `;
|
||||
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user