Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 | ||
|
|
d2851982a6 | ||
|
|
cc5dce18e2 | ||
|
|
6084ed4d3c | ||
|
|
c3f6db69e9 | ||
|
|
8e2221cb8f | ||
|
|
7324982f47 | ||
|
|
f20d64b4f2 | ||
|
|
aa0b32a142 | ||
|
|
c8a907538e | ||
|
|
dd5573cc10 | ||
|
|
071711dafc | ||
|
|
bc74fba3f5 | ||
|
|
694dc34b19 | ||
|
|
b3b08b4ff2 | ||
|
|
9b0af07607 | ||
|
|
c044dbde87 | ||
|
|
1396b3a70c | ||
|
|
4c9ee5923e | ||
|
|
25e58a6425 | ||
|
|
7d13b2a9fe | ||
|
|
e610aba0e5 | ||
|
|
3941dd4ecc | ||
|
|
872e5eba73 | ||
|
|
8c3f816407 | ||
|
|
13f7aa95a3 | ||
|
|
8e5a50623c | ||
|
|
d791e52d82 | ||
|
|
fb04edc497 | ||
|
|
2fe7530cd4 | ||
|
|
4389bf2013 | ||
|
|
50f80b975d | ||
|
|
1ccb30f0a7 | ||
|
|
d44a504c04 | ||
|
|
62ac931468 | ||
|
|
6873bc92bb | ||
|
|
02f0c0c259 | ||
|
|
db317283ac | ||
|
|
3ebe8d0fac | ||
|
|
a074c10205 | ||
|
|
55b11598f5 | ||
|
|
ebbbbc2696 |
No files matched your search
@@ -117,6 +117,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
@@ -134,7 +143,12 @@ jobs:
|
||||
- name: Build Windows (All Architectures)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
|
||||
|
||||
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
|
||||
# The combined x64+ia32 installer is never uploaded.
|
||||
- name: Fix Windows update files
|
||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -178,6 +192,17 @@ jobs:
|
||||
run: |
|
||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
||||
|
||||
- name: Upload Windows update files
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_update_meta
|
||||
path: |
|
||||
release/*.yml
|
||||
release/termix_windows_x64_nsis.exe.blockmap
|
||||
!release/builder-debug.yml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows x64 Portable
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
@@ -225,6 +250,15 @@ jobs:
|
||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
@@ -234,12 +268,12 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEBUG: electron-builder
|
||||
run: npm run build && npx electron-builder --linux --x64
|
||||
run: npm run build && npx electron-builder --linux --x64 --publish never
|
||||
|
||||
- name: Build Linux arm64 and armv7l
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --linux --arm64 --armv7l
|
||||
run: npx electron-builder --linux --arm64 --armv7l --publish never
|
||||
|
||||
- name: Rename Linux artifacts for consistency
|
||||
run: |
|
||||
@@ -255,6 +289,19 @@ jobs:
|
||||
|
||||
cd ..
|
||||
|
||||
node scripts/fix-update-info.cjs \
|
||||
--rename termix_linux_amd64_deb.deb=termix_linux_x64_deb.deb \
|
||||
--rename termix_linux_x86_64_appimage.AppImage=termix_linux_x64_appimage.AppImage \
|
||||
release/*-linux*.yml
|
||||
|
||||
- name: Upload Linux update files
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/*-linux*.yml') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_update_meta
|
||||
path: release/*-linux*.yml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
@@ -439,6 +486,15 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
@@ -494,7 +550,7 @@ jobs:
|
||||
run: |
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never
|
||||
|
||||
- name: Clean up MAS keychain before DMG build
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
@@ -553,7 +609,24 @@ jobs:
|
||||
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
|
||||
npm run build
|
||||
fi
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
npx electron-builder --mac dmg zip --universal --x64 --arm64 --publish never
|
||||
|
||||
# The updater picks the x64 or arm64 zip, so the universal one is left out.
|
||||
- name: Fix macOS update files
|
||||
run: node scripts/fix-update-info.cjs --drop universal release/*-mac.yml
|
||||
|
||||
- name: Upload macOS update files
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/*-mac.yml') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_update_meta
|
||||
path: |
|
||||
release/*-mac.yml
|
||||
release/termix_macos_x64_update.zip
|
||||
release/termix_macos_x64_update.zip.blockmap
|
||||
release/termix_macos_arm64_update.zip
|
||||
release/termix_macos_arm64_update.zip.blockmap
|
||||
retention-days: 30
|
||||
|
||||
- name: Verify macOS sharp packaging
|
||||
run: |
|
||||
@@ -771,144 +844,6 @@ jobs:
|
||||
path: choco-build/*.nupkg
|
||||
retention-days: 30
|
||||
|
||||
submit-to-flatpak:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download and prepare AppImage info from public release
|
||||
id: appimage-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
mkdir -p release_assets
|
||||
|
||||
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
|
||||
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_X64_NAME"
|
||||
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
|
||||
echo "Downloading x64 AppImage from $URL_X64"
|
||||
curl -L -o "$PATH_X64" "$URL_X64"
|
||||
chmod +x "$PATH_X64"
|
||||
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
|
||||
|
||||
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
|
||||
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_ARM64_NAME"
|
||||
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
|
||||
echo "Downloading arm64 AppImage from $URL_ARM64"
|
||||
curl -L -o "$PATH_ARM64" "$URL_ARM64"
|
||||
chmod +x "$PATH_ARM64"
|
||||
CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')
|
||||
|
||||
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
|
||||
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y imagemagick
|
||||
|
||||
- name: Prepare Flatpak submission files
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
|
||||
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
|
||||
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
|
||||
|
||||
mkdir -p flatpak-submission
|
||||
|
||||
cp packaging/flatpak/com.karmaa.termix.yml flatpak-submission/
|
||||
cp packaging/flatpak/com.karmaa.termix.desktop flatpak-submission/
|
||||
cp packaging/flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
|
||||
cp packaging/flatpak/flathub.json flatpak-submission/
|
||||
|
||||
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
|
||||
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: Upload Flatpak submission as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flatpak-submission
|
||||
path: flatpak-submission/*
|
||||
retention-days: 30
|
||||
|
||||
- name: Check for Flathub token
|
||||
id: check_flathub_token
|
||||
run: |
|
||||
if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then
|
||||
echo "has_token=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Open PR on Flathub repo
|
||||
if: steps.check_flathub_token.outputs.has_token == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
|
||||
VERSION: ${{ steps.package-version.outputs.version }}
|
||||
run: |
|
||||
FLATHUB_REPO="flathub/com.karmaa.termix"
|
||||
BRANCH="release-$VERSION"
|
||||
|
||||
git config --global user.name "LukeGus"
|
||||
git config --global user.email "bugattiguy527@gmail.com"
|
||||
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo
|
||||
cd flathub-repo
|
||||
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
cp ../flatpak-submission/com.karmaa.termix.yml ./
|
||||
cp ../flatpak-submission/com.karmaa.termix.desktop ./
|
||||
cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./
|
||||
cp ../flatpak-submission/flathub.json ./
|
||||
cp ../flatpak-submission/com.karmaa.termix.svg ./
|
||||
cp ../flatpak-submission/icon-256.png ./
|
||||
cp ../flatpak-submission/icon-128.png ./
|
||||
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "No changes to submit for $VERSION; Flathub repo already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "Update to $VERSION"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true)
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
gh pr create --repo "$FLATHUB_REPO" \
|
||||
--base master \
|
||||
--head "$BRANCH" \
|
||||
--title "Update to $VERSION" \
|
||||
--body "Automated release update to version $VERSION."
|
||||
else
|
||||
echo "PR #$EXISTING_PR already open for $BRANCH."
|
||||
fi
|
||||
|
||||
submit-to-homebrew:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||
@@ -1007,18 +942,15 @@ jobs:
|
||||
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Update files go up last: the desktop updater treats a release as ready
|
||||
# once its update file is there, so every installer must already be up.
|
||||
- name: Upload artifacts to latest release
|
||||
run: |
|
||||
cd artifacts
|
||||
for dir in */; do
|
||||
cd "$dir"
|
||||
for file in *;
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||
fi
|
||||
done
|
||||
cd ..
|
||||
find artifacts -type f ! -name '*.yml' -print0 | while IFS= read -r -d '' file; do
|
||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||
done
|
||||
find artifacts -type f -name '*.yml' -print0 | while IFS= read -r -d '' file; do
|
||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -1050,6 +982,15 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
@@ -1188,7 +1129,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" --publish never
|
||||
|
||||
- name: Verify macOS sharp packaging
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
@@ -1212,6 +1153,49 @@ jobs:
|
||||
--locales "en-US"
|
||||
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Apple won't create a new version while the last one is still in
|
||||
# review. Pull it out so deliver can rename it to this version.
|
||||
- name: Cancel pending App Store review
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
LANE_DIR="$RUNNER_TEMP/asc_cancel/fastlane"
|
||||
mkdir -p "$LANE_DIR"
|
||||
|
||||
cat > "$LANE_DIR/Fastfile" <<'EOF'
|
||||
default_platform(:mac)
|
||||
|
||||
lane :cancel_pending_review do
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file("/tmp/asc_keys/api_key.json")
|
||||
app = Spaceship::ConnectAPI::App.find("com.karmaa.termix")
|
||||
UI.user_error!("App not found") unless app
|
||||
|
||||
app.get_app_store_versions(filter: { platform: "MAC_OS" }, limit: 5).each do |v|
|
||||
UI.message("Version #{v.version_string}: #{v.app_version_state || v.app_store_state}")
|
||||
end
|
||||
|
||||
active = %w[WAITING_FOR_REVIEW IN_REVIEW UNRESOLVED_ISSUES]
|
||||
canceled = false
|
||||
app.get_review_submissions(filter: { platform: "MAC_OS" }).each do |sub|
|
||||
UI.message("Review submission #{sub.id}: #{sub.state}")
|
||||
next unless active.include?(sub.state)
|
||||
sub.cancel_submission
|
||||
canceled = true
|
||||
UI.important("Canceled review submission #{sub.id}")
|
||||
end
|
||||
next unless canceled
|
||||
|
||||
30.times do
|
||||
busy = app.get_review_submissions(filter: { platform: "MAC_OS" }).any? do |sub|
|
||||
(active + %w[CANCELING]).include?(sub.state)
|
||||
end
|
||||
break unless busy
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
EOF
|
||||
|
||||
cd "$RUNNER_TEMP/asc_cancel" && fastlane cancel_pending_review
|
||||
|
||||
- name: Upload and submit to Mac App Store
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
|
||||
@@ -24,6 +24,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:sdk
|
||||
|
||||
|
||||
@@ -161,15 +161,62 @@ jobs:
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify-beta:
|
||||
# Translates new or changed strings with the Termix LLM server and commits
|
||||
# them to the dev branch, so the beta or stable build includes them. If the
|
||||
# server is down it warns and the release goes on with what is there.
|
||||
translate:
|
||||
needs: [prep]
|
||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha || needs.prep.outputs.dev_branch }}
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.TERMIX_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Translate
|
||||
uses: Termix-SSH/Termix-Registry/translate@main
|
||||
with:
|
||||
source: src/ui/locales/en.json
|
||||
out: src/ui/locales/translated
|
||||
languages: src/ui/locales/languages.json
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
id: commit
|
||||
run: |
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit these translation changes:"
|
||||
git --no-pager diff --cached --stat
|
||||
else
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify-beta:
|
||||
needs: [prep, translate]
|
||||
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout beta commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha }}
|
||||
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -181,6 +228,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
- name: Cache bundled plugins
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: node_modules/.cache/termix-bundled-plugins
|
||||
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||
enableCrossOsArchive: true
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:sdk
|
||||
|
||||
@@ -197,7 +253,7 @@ jobs:
|
||||
run: npm run test
|
||||
|
||||
normalize:
|
||||
needs: [prep]
|
||||
needs: [prep, translate]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -284,8 +340,8 @@ jobs:
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
|
||||
merge-to-main:
|
||||
needs: [prep, normalize, sync-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
|
||||
needs: [prep, translate, normalize, sync-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.translate.result == 'success' || needs.translate.result == 'skipped') && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
main_sha: ${{ steps.merge.outputs.main_sha }}
|
||||
@@ -353,14 +409,14 @@ jobs:
|
||||
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
needs: [prep, verify-beta, merge-to-main]
|
||||
needs: [prep, translate, verify-beta, merge-to-main]
|
||||
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
||||
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
@@ -418,7 +474,7 @@ jobs:
|
||||
fi
|
||||
|
||||
create-beta-release:
|
||||
needs: [prep, docker]
|
||||
needs: [prep, translate, docker]
|
||||
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -427,7 +483,7 @@ jobs:
|
||||
- name: Checkout beta commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.sha }}
|
||||
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -440,7 +496,7 @@ jobs:
|
||||
node scripts/generate-beta-body.cjs \
|
||||
--version "${{ needs.prep.outputs.version }}" \
|
||||
--branch "${{ needs.prep.outputs.dev_branch }}" \
|
||||
--sha "${{ needs.prep.outputs.sha }}" \
|
||||
--sha "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}" \
|
||||
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
|
||||
|
||||
- name: Create beta release
|
||||
@@ -456,11 +512,19 @@ jobs:
|
||||
else
|
||||
gh release create "$TAG" --repo ${{ github.repository }} \
|
||||
--title "$TITLE" --notes-file BETA_BODY.md \
|
||||
--prerelease --target "${{ needs.prep.outputs.sha }}"
|
||||
--prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}"
|
||||
fi
|
||||
|
||||
electron-release:
|
||||
needs: [prep, merge-to-main, docker, create-release, create-beta-release]
|
||||
needs:
|
||||
[
|
||||
prep,
|
||||
translate,
|
||||
merge-to-main,
|
||||
docker,
|
||||
create-release,
|
||||
create-beta-release,
|
||||
]
|
||||
if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
@@ -468,12 +532,12 @@ jobs:
|
||||
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||
secrets: inherit
|
||||
|
||||
electron-submit:
|
||||
needs: [prep, merge-to-main, electron-release]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
|
||||
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
@@ -483,7 +547,7 @@ jobs:
|
||||
|
||||
cask-commit-back:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
if: ${{ always() && needs.electron-release.result == 'success' && needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -541,8 +605,48 @@ jobs:
|
||||
|
||||
# The docs site reads the env catalog, API spec and download list from the
|
||||
# newest stable release.
|
||||
docs-refresh:
|
||||
# Docs written for this release live on dev-X.Y.Z in the Docs repo. Squash
|
||||
# merge it into main once the release is out. No branch means nothing to do.
|
||||
docs-merge:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Merge the docs dev branch into main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||
REPO: Termix-SSH/Docs
|
||||
VERSION: ${{ needs.prep.outputs.version }}
|
||||
run: |
|
||||
BRANCH="dev-$VERSION"
|
||||
if ! gh api "repos/$REPO/branches/$BRANCH" > /dev/null 2>&1; then
|
||||
echo "$REPO has no $BRANCH branch, nothing to merge."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
AHEAD=$(gh api "repos/$REPO/compare/main...$BRANCH" -q .ahead_by)
|
||||
if [ "$AHEAD" = "0" ]; then
|
||||
echo "$BRANCH has nothing main doesn't, deleting it."
|
||||
gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$BRANCH" --base main --state open --json number -q '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
PR_NUMBER=$(gh pr create --repo "$REPO" --base main --head "$BRANCH" \
|
||||
--title "v$VERSION" --body "Docs for Termix v$VERSION." | grep -oE '[0-9]+$')
|
||||
fi
|
||||
|
||||
gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --admin --delete-branch
|
||||
|
||||
STATE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state -q .state)
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "Docs PR #$PR_NUMBER did not merge (state: $STATE)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docs-refresh:
|
||||
needs: [prep, electron-release, docs-merge]
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
retranslate:
|
||||
description: "Translate every string again, not only new or changed ones"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: translate-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
if: ${{ startsWith(github.ref, 'refs/heads/dev-') }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.TERMIX_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Translate
|
||||
uses: Termix-SSH/Termix-Registry/translate@main
|
||||
with:
|
||||
source: src/ui/locales/en.json
|
||||
out: src/ui/locales/translated
|
||||
languages: src/ui/locales/languages.json
|
||||
retranslate: ${{ inputs.retranslate }}
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
run: |
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${GITHUB_REF_NAME}"
|
||||
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.2
|
||||
|
||||
### Added
|
||||
|
||||
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
||||
- Sort the dashboard's Host Status card by name, IP address or status
|
||||
|
||||
### Changed
|
||||
|
||||
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
||||
|
||||
### Fixed
|
||||
|
||||
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
|
||||
- The local terminal failed to start in the universal macOS build
|
||||
- Hosts shared with you never showed up on a linked desktop
|
||||
- A linked desktop could never turn on an unsigned plugin the server had enabled
|
||||
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
|
||||
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
|
||||
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
|
||||
- Usernames are now unique
|
||||
- Importing user data keeps foreign keys on
|
||||
- Trusted proxy login checks the address nginx saw, not nginx itself
|
||||
- The desktop login frame only trusts the server's own origin
|
||||
- External login URLs and callback ports are checked on the desktop app
|
||||
- A changed jump host key is no longer accepted without asking
|
||||
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
|
||||
- Two connects to the same host at once could overwrite each other's connection
|
||||
- Indonesian uses the same technical terms everywhere
|
||||
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
|
||||
- nginx answered 404 instead of 502 while the backend was down
|
||||
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
|
||||
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
|
||||
|
||||
## 26.10.1
|
||||
|
||||
### Added
|
||||
|
||||
- Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay
|
||||
- The desktop app can update itself. Pick Automatic, Ask first or Never in Settings, Updates. Store and package manager installs keep updating the way they do now
|
||||
- Community plugins. Turn on the community registry in Plugins, Registries to browse and install plugins made by other people, each version reviewed before it is listed
|
||||
- Custom plugin registries. Add your own registry by its index URL; its signing key is pinned when you add it
|
||||
|
||||
### Changed
|
||||
|
||||
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
|
||||
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
|
||||
- The Plugins tab shows how many times each plugin was downloaded, in a Downloads column
|
||||
- Plugins, Browse hides plugins you already have. Turn on Show installed to see them
|
||||
|
||||
### Fixed
|
||||
|
||||
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
|
||||
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
|
||||
- Plugin updates on the desktop app, synced or installed from the Plugins tab, failed to start with "Cannot find package"
|
||||
- The desktop app showed plugins that only run on the server as not running and out of date
|
||||
- Keep the terminal WebSocket open past nginx's 60s default
|
||||
- Plugin download counts now show in the Plugins tab without waiting for the background check
|
||||
- Clicking into a saved password, key passphrase or sudo password field and saving without typing erased it, which stopped sudo autofill
|
||||
- The desktop app now says when its data is from 2.8 or older and needs 2.9 first, instead of only saying the local server could not start
|
||||
- Client tunnels on the desktop app stopped with "Endpoint SSH connection closed" when a reverse proxy dropped their idle connection
|
||||
- Typing right after opening a host from the sidebar went to the host search instead of the new session
|
||||
- SSH config import failed on files that put a tab or `=` between a keyword and its value
|
||||
- App icons and the favicon have rounded corners again
|
||||
|
||||
## 26.10.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +25,7 @@ Termix is a small core plus plugins. This repo is the core: hosts, credentials,
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Termix-Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
|
||||
## Useful commands
|
||||
|
||||
@@ -38,21 +38,21 @@ npm run format # format the code with Prettier
|
||||
|
||||
## Making a change
|
||||
|
||||
1. **Fork the repository**: Click "Fork" at the top right of the [repository page](https://github.com/Termix-SSH/Termix).
|
||||
2. **Create a branch**:
|
||||
1. Fork the [repo](https://github.com/Termix-SSH/Termix).
|
||||
2. Create a branch:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes** and add or update tests for them.
|
||||
4. **Commit your changes**:
|
||||
3. Make your changes and add or update tests for them.
|
||||
4. Commit your changes:
|
||||
```sh
|
||||
git commit -m "feat: add my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
5. Push to your fork:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
6. **Open a pull request** against the current `dev-*` branch with a clear description.
|
||||
6. Open a pull request against the current `dev-*` branch with a clear description.
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -72,4 +72,4 @@ Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Tem
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Please be as detailed as possible, preferably in English. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Add as much detail as you can, in English if possible. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "2.9.2"
|
||||
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
|
||||
version "26.10.1"
|
||||
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
@@ -12,6 +12,8 @@ cask "termix" do
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
auto_updates true
|
||||
|
||||
app "Termix.app"
|
||||
|
||||
zap trash: [
|
||||
|
||||
@@ -24,30 +24,30 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Termix is a free, open source, self-hosted platform for managing your servers, built on plugins. The core is small. It keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, turn off or remove.
|
||||
Termix is a free, open source, self-hosted server management platform built on plugins. The core keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, off or remove.
|
||||
|
||||
It runs on web, desktop and mobile, and it is a self-hosted alternative to Termius that stays free forever.
|
||||
It runs on web, desktop and mobile. Think of it as a free, self-hosted Termius.
|
||||
|
||||
<br />
|
||||
|
||||
## Try the Demo
|
||||
|
||||
Want to see it before you install it? Try the demo at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
Try it at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
|
||||
<br />
|
||||
|
||||
## Core
|
||||
|
||||
What every Termix install has, with or without plugins:
|
||||
What every install has, with or without plugins:
|
||||
|
||||
- **Hosts:** Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- **Credentials:** Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- **Sharing and Roles:** Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- **Accounts:** Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- **Security:** Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- **Desktop Sync:** The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- **Interface:** Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- **Databases:** SQLite by default, with PostgreSQL and MySQL supported too
|
||||
- Hosts: Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- Credentials: Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- Sharing and Roles: Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- Accounts: Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- Security: Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- Desktop Sync: The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- Interface: Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- Databases: SQLite by default, with PostgreSQL and MySQL supported too
|
||||
|
||||
<br />
|
||||
|
||||
@@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
||||
|
||||
<br />
|
||||
|
||||
@@ -105,27 +105,27 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Web</b></td>
|
||||
<td>Any modern browser (Chrome, Safari, Firefox) · PWA support</td>
|
||||
<td>Any modern browser (Chrome, Safari, Firefox) | PWA support</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||
<td>Portable · EXE and MSI Installer · Chocolatey</td>
|
||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
|
||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||
<td>Apple App Store · DMG · Homebrew</td>
|
||||
<td>Apple App Store | DMG | Homebrew</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||
<td>Apple App Store · IPA</td>
|
||||
<td>Apple App Store | IPA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Android</b> <sub>v7.0+</sub></td>
|
||||
<td>Google Play Store · APK</td>
|
||||
<td>Google Play Store | APK</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -133,7 +133,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
|
||||
## Installation
|
||||
|
||||
Visit the [Termix Docs](https://docs.termix.site/install) for full install steps on every platform.
|
||||
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
||||
|
||||
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
|
||||
|
||||
@@ -178,15 +178,15 @@ networks:
|
||||
|
||||
### Cloud Hosting
|
||||
|
||||
You can run Termix on a VPS instead of inside your own network. If Termix runs on the network it manages, an outage takes Termix down with it, right when you need it to fix things. Running it somewhere else keeps it reachable, gives you a static IP and lets you get in from anywhere without a VPN or port forward.
|
||||
If Termix runs on the network it manages, an outage takes it down right when you need it. A VPS keeps it reachable, gives you a static IP and works from anywhere without a VPN or port forward.
|
||||
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix, and the docs have a step by step guide for their VPS platform.
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix and the docs have a setup guide for their VPS.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains and development time.
|
||||
Termix has no paid plans. Donations cover servers, domains and development time.
|
||||
|
||||
[Donate](https://donate.termix.site/)
|
||||
|
||||
@@ -194,7 +194,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
|
||||
|
||||
## Sponsors
|
||||
|
||||
Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
|
||||
For a paid sponsor spot, email [mail@termix.site](mailto:mail@termix.site).
|
||||
|
||||
<!-- SPONSORS:START -->
|
||||
|
||||
@@ -202,14 +202,6 @@ Interested in a paid placement to support development? Email [mail@termix.site](
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://termix.site/img/sponsors/digitalocean.svg" height="40" alt="DigitalOcean" />
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://termix.site/img/sponsors/crowdin.svg" height="40" alt="Crowdin" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://termix.site/img/sponsors/blacksmith.svg" height="40" alt="Blacksmith" />
|
||||
</a>
|
||||
@@ -250,7 +242,7 @@ Bugs and ideas for Termix itself (login, hosts, credentials, sharing, sync, the
|
||||
|
||||
Every plugin has its own repo, so a problem with the terminal, file manager, Docker, remote desktop or any other plugin goes in that plugin's repo (see the table above, or use Report an issue on the plugin's page in the app, which fills in your versions for you). Not sure where it goes? Open it here and it will be moved.
|
||||
|
||||
You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
You need a GitHub account. Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server. Update videos are on [YouTube](https://www.youtube.com/@TermixSSH/videos).
|
||||
|
||||
@@ -258,4 +250,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
|
||||
Apache License 2.0. See [LICENSE](LICENSE).
|
||||
@@ -34,7 +34,9 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build:backend
|
||||
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||
npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
@@ -72,12 +74,15 @@ COPY vendor ./vendor
|
||||
|
||||
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
||||
|
||||
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
|
||||
# OpenSSL instead (chacha20-poly1305 is then left out).
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
node scripts/patch-ssh2-agent.cjs && \
|
||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||
npm rebuild bcryptjs ssh2 && \
|
||||
npm rebuild bcryptjs && \
|
||||
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
|
||||
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
|
||||
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
|
||||
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
|
||||
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
||||
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
|
||||
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -25,14 +25,14 @@
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
|
||||
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
|
||||
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
||||
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
|
||||
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -41,8 +41,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
||||
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
|
||||
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -51,20 +51,20 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
|
||||
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
|
||||
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
|
||||
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
|
||||
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
||||
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
|
||||
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -73,32 +73,32 @@
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
|
||||
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
|
||||
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
|
||||
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
|
||||
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
|
||||
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
|
||||
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
|
||||
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
|
||||
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
||||
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
|
||||
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -107,14 +107,14 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
|
||||
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
|
||||
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
||||
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
|
||||
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
@@ -123,20 +123,20 @@
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
|
||||
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
|
||||
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
|
||||
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
|
||||
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
||||
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
|
||||
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -145,8 +145,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
||||
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
|
||||
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -155,8 +155,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
|
||||
"sha256": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
|
||||
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -166,20 +166,20 @@
|
||||
{
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
|
||||
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
|
||||
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
|
||||
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
|
||||
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
||||
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
|
||||
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -188,20 +188,20 @@
|
||||
{
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
|
||||
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
|
||||
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
|
||||
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
|
||||
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
||||
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
|
||||
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -210,8 +210,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
||||
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
|
||||
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -220,32 +220,32 @@
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
|
||||
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
|
||||
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
|
||||
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
|
||||
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
|
||||
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
|
||||
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
|
||||
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
|
||||
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
||||
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
|
||||
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -254,8 +254,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
||||
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
|
||||
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -28,7 +28,11 @@ services:
|
||||
# Trusted reverse-proxy authentication is disabled by default. When
|
||||
# enabled, do not expose this container directly to untrusted clients.
|
||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
|
||||
# List the address your authenticating proxy connects from, as this
|
||||
# container sees it (for a proxy container on termix-net, its IP there).
|
||||
# Keep it narrow: published ports can reach the container from the
|
||||
# Docker gateway, so do not trust whole private ranges.
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
|
||||
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
||||
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
||||
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
||||
|
||||
@@ -200,6 +200,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -425,6 +428,16 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
# A terminal is quiet most of the time - waiting on a prompt, reading
|
||||
# output, running something long. Every console plugin dials this route
|
||||
# (terminal, serial, remote desktop), and nginx's default
|
||||
# proxy_read_timeout of 60s closed the socket about a minute into any
|
||||
# pause, which reaches the user as the SSH server disconnecting. The
|
||||
# plugin WebSocket routes below already ask for 86400s; this is the same
|
||||
# class of socket, so it asks for the same.
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
@@ -463,9 +476,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -394,6 +397,16 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
# A terminal is quiet most of the time - waiting on a prompt, reading
|
||||
# output, running something long. Every console plugin dials this route
|
||||
# (terminal, serial, remote desktop), and nginx's default
|
||||
# proxy_read_timeout of 60s closed the socket about a minute into any
|
||||
# pause, which reaches the user as the SSH server disconnecting. The
|
||||
# plugin WebSocket routes below already ask for 86400s; this is the same
|
||||
# class of socket, so it asks for the same.
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
@@ -432,9 +445,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
|
||||
)
|
||||
)
|
||||
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
|
||||
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
|
||||
WHERE ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
|
||||
@@ -498,6 +498,20 @@
|
||||
"when": 1791550497926,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "5",
|
||||
"when": 1791755801066,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "5",
|
||||
"when": 1791935283211,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
|
||||
@@ -498,6 +498,20 @@
|
||||
"when": 1791550496661,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1791755800419,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1791935282340,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||
@@ -463,6 +463,20 @@
|
||||
"when": 1791550495357,
|
||||
"tag": "0065_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "6",
|
||||
"when": 1791755799726,
|
||||
"tag": "0066_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "6",
|
||||
"when": 1791935281343,
|
||||
"tag": "0067_unique_username",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"appId": "com.karmaa.termix",
|
||||
"productName": "Termix",
|
||||
"publish": null,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://github.com/Termix-SSH/Termix/releases/latest/download"
|
||||
},
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
@@ -114,8 +117,13 @@
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["universal", "x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "termix_macos_${arch}_update.${ext}",
|
||||
"icon": "public/icon.icns",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
@@ -148,6 +156,6 @@
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
},
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"generateUpdatesFilesForAllChannels": false,
|
||||
"msiProjectCreated": "packaging/build/msi-shortcuts.cjs"
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Desktop self updates. The release is picked from the GitHub releases list
|
||||
// instead of electron-updater's GitHub provider, because the repo also holds
|
||||
// SDK and 2.9.x releases, and a new release exists a while before its files
|
||||
// finish uploading. The channel file is uploaded last, so a release counts
|
||||
// only once it has one for this platform.
|
||||
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "Termix-SSH/Termix";
|
||||
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
||||
const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/`;
|
||||
const MODES = ["automatic", "prompt", "never"];
|
||||
const DEFAULT_MODE = "prompt";
|
||||
const FIRST_CHECK_DELAY = 30 * 1000;
|
||||
const CHECK_INTERVAL = 6 * 60 * 60 * 1000;
|
||||
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
|
||||
function parseVersion(value) {
|
||||
const match = VERSION.exec(String(value || "").trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
parts: match.slice(1, 4).map(Number),
|
||||
beta: match[4] == null ? null : Number(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
// A beta sorts before the release it leads up to.
|
||||
function compareVersions(a, b) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
|
||||
}
|
||||
if (a.beta === b.beta) return 0;
|
||||
if (a.beta == null) return 1;
|
||||
if (b.beta == null) return -1;
|
||||
return a.beta - b.beta;
|
||||
}
|
||||
|
||||
// Matches the names electron-builder writes and electron-updater reads.
|
||||
function channelFile(channel, platform, arch) {
|
||||
if (platform === "win32") return `${channel}.yml`;
|
||||
if (platform === "darwin") return `${channel}-mac.yml`;
|
||||
return `${channel}-linux${arch === "x64" ? "" : `-${arch}`}.yml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest release this install should move to, or null. Stable installs
|
||||
* only see stable releases; a beta install also sees newer betas.
|
||||
*/
|
||||
function resolveRelease(releases, { currentVersion, platform, arch }) {
|
||||
const current = parseVersion(currentVersion);
|
||||
if (!current || !Array.isArray(releases)) return null;
|
||||
const allowBeta = current.beta != null;
|
||||
|
||||
const newest = releases
|
||||
.filter((release) => release && !release.draft)
|
||||
.map((release) => ({ release, version: parseVersion(release.tag_name) }))
|
||||
.filter(
|
||||
({ release, version }) =>
|
||||
version &&
|
||||
release.tag_name.startsWith("v") &&
|
||||
(allowBeta || version.beta == null) &&
|
||||
compareVersions(version, current) > 0,
|
||||
)
|
||||
.sort((a, b) => compareVersions(b.version, a.version))[0];
|
||||
if (!newest) return null;
|
||||
|
||||
const { release, version } = newest;
|
||||
const channel = version.beta == null ? "latest" : "beta";
|
||||
const file = channelFile(channel, platform, arch);
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
if (!assets.some((asset) => asset && asset.name === file)) return null;
|
||||
|
||||
return {
|
||||
tag: release.tag_name,
|
||||
version: release.tag_name.slice(1),
|
||||
channel,
|
||||
feedUrl: `${DOWNLOAD_BASE}${release.tag_name}`,
|
||||
releaseUrl:
|
||||
release.html_url ||
|
||||
`https://github.com/${REPO}/releases/tag/${release.tag_name}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How this copy of the app was installed, which decides whether it can
|
||||
* replace itself:
|
||||
* - auto: NSIS, DMG, AppImage or deb, updated in place
|
||||
* - manual: MSI, Chocolatey, Flatpak, portable builds and 32-bit Windows, told where to download
|
||||
* - store: Mac App Store
|
||||
* - disabled: dev builds
|
||||
*/
|
||||
function getInstallKind({
|
||||
platform,
|
||||
arch,
|
||||
isPackaged,
|
||||
mas,
|
||||
env,
|
||||
execPath,
|
||||
resourcesPath,
|
||||
exists,
|
||||
readFile,
|
||||
}) {
|
||||
if (!isPackaged) return "disabled";
|
||||
if (mas) return "store";
|
||||
|
||||
if (platform === "linux") {
|
||||
if (env.FLATPAK_ID || exists("/.flatpak-info")) return "manual";
|
||||
if (env.APPIMAGE) return "auto";
|
||||
try {
|
||||
const type = readFile(path.join(resourcesPath, "package-type")).trim();
|
||||
// The AUR package reuses the deb's files, so ask dpkg if it owns us.
|
||||
if (type === "deb" && exists("/var/lib/dpkg/info/termix.list")) {
|
||||
return "auto";
|
||||
}
|
||||
} catch {
|
||||
// Not a package install.
|
||||
}
|
||||
return "manual";
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
if (arch !== "x64") return "manual";
|
||||
const uninstaller = path.join(
|
||||
path.dirname(execPath),
|
||||
"Uninstall Termix.exe",
|
||||
);
|
||||
return exists(uninstaller) ? "auto" : "manual";
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
// Still running from the mounted DMG.
|
||||
return execPath.startsWith("/Volumes/") ? "manual" : "auto";
|
||||
}
|
||||
|
||||
return "manual";
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
return MODES.includes(mode) ? mode : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function createUpdater({
|
||||
currentVersion,
|
||||
platform,
|
||||
arch,
|
||||
kind,
|
||||
getMode,
|
||||
saveMode,
|
||||
fetchReleases,
|
||||
loadAutoUpdater,
|
||||
send,
|
||||
log,
|
||||
beforeInstall,
|
||||
}) {
|
||||
const state = {
|
||||
kind,
|
||||
mode: normalizeMode(getMode()),
|
||||
currentVersion,
|
||||
status: "idle",
|
||||
version: null,
|
||||
releaseUrl: null,
|
||||
progress: null,
|
||||
error: null,
|
||||
};
|
||||
const enabled = kind === "auto" || kind === "manual";
|
||||
let updater = null;
|
||||
let timer = null;
|
||||
let checkedOnce = false;
|
||||
|
||||
function update(patch) {
|
||||
Object.assign(state, patch);
|
||||
send({ ...state });
|
||||
}
|
||||
|
||||
function getUpdater() {
|
||||
if (updater) return updater;
|
||||
updater = loadAutoUpdater();
|
||||
updater.logger = {
|
||||
info: (...args) => log("[updater]", ...args),
|
||||
warn: (...args) => log("[updater]", ...args),
|
||||
error: (...args) => log("[updater]", ...args),
|
||||
debug: () => {},
|
||||
};
|
||||
updater.autoInstallOnAppQuit = true;
|
||||
updater.on("update-available", (info) =>
|
||||
update({
|
||||
status: updater.autoDownload ? "downloading" : "available",
|
||||
version: info.version,
|
||||
progress: null,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
updater.on("update-not-available", () =>
|
||||
update({ status: "up-to-date", error: null }),
|
||||
);
|
||||
updater.on("download-progress", (progress) =>
|
||||
update({
|
||||
status: "downloading",
|
||||
progress: Math.round(progress.percent || 0),
|
||||
}),
|
||||
);
|
||||
updater.on("update-downloaded", (info) =>
|
||||
update({
|
||||
status: "downloaded",
|
||||
version: info.version,
|
||||
progress: 100,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
updater.on("error", (error) => {
|
||||
log("[updater] error:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
});
|
||||
return updater;
|
||||
}
|
||||
|
||||
async function check() {
|
||||
if (!enabled) return { ...state };
|
||||
if (["checking", "downloading", "downloaded"].includes(state.status)) {
|
||||
return { ...state };
|
||||
}
|
||||
checkedOnce = true;
|
||||
update({ status: "checking", error: null });
|
||||
|
||||
let release;
|
||||
try {
|
||||
release = resolveRelease(await fetchReleases(), {
|
||||
currentVersion,
|
||||
platform,
|
||||
arch,
|
||||
});
|
||||
} catch (error) {
|
||||
log("[updater] release lookup failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
update({ status: "up-to-date" });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
update({ version: release.version, releaseUrl: release.releaseUrl });
|
||||
if (kind === "manual") {
|
||||
update({ status: "available" });
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
try {
|
||||
const auto = getUpdater();
|
||||
auto.setFeedURL({ provider: "generic", url: release.feedUrl });
|
||||
auto.channel = release.channel;
|
||||
auto.allowDowngrade = false;
|
||||
auto.autoDownload = state.mode === "automatic";
|
||||
await auto.checkForUpdates();
|
||||
} catch (error) {
|
||||
log("[updater] check failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (kind !== "auto" || state.status !== "available") return { ...state };
|
||||
update({ status: "downloading", progress: 0 });
|
||||
try {
|
||||
await getUpdater().downloadUpdate();
|
||||
} catch (error) {
|
||||
log("[updater] download failed:", error?.message || String(error));
|
||||
update({ status: "error", error: error?.message || String(error) });
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function install() {
|
||||
if (kind !== "auto" || state.status !== "downloaded") return false;
|
||||
// The tray keeps windows alive on close, so mark the quit first.
|
||||
beforeInstall?.();
|
||||
setImmediate(() => getUpdater().quitAndInstall());
|
||||
return true;
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!MODES.includes(mode)) return { ...state };
|
||||
saveMode(mode);
|
||||
update({ mode });
|
||||
if (updater) updater.autoDownload = mode === "automatic";
|
||||
if (mode !== "never" && !checkedOnce) void check();
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!enabled || timer) return;
|
||||
setTimeout(() => {
|
||||
if (state.mode !== "never") void check();
|
||||
}, FIRST_CHECK_DELAY).unref?.();
|
||||
timer = setInterval(() => {
|
||||
if (state.mode !== "never") void check();
|
||||
}, CHECK_INTERVAL);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => ({ ...state }),
|
||||
check,
|
||||
download,
|
||||
install,
|
||||
setMode,
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MODE,
|
||||
RELEASES_API,
|
||||
channelFile,
|
||||
compareVersions,
|
||||
createUpdater,
|
||||
getInstallKind,
|
||||
normalizeMode,
|
||||
parseVersion,
|
||||
resolveRelease,
|
||||
};
|
||||
@@ -35,7 +35,13 @@ function parseConflictingPort(stderr) {
|
||||
* stderr. Always returns a failure -- see the note above on why every exit
|
||||
* that reaches this point counts as one.
|
||||
*/
|
||||
// Part of PRE_29_DATABASE_ERROR in src/backend/database/db/upgrade-guard.ts.
|
||||
const OLD_DATABASE_MARKER = "This database is from Termix 2.8 or older";
|
||||
|
||||
function classifyBackendFailure(stderr = "") {
|
||||
if (stderr.includes(OLD_DATABASE_MARKER)) {
|
||||
return { reason: "old-database", port: null };
|
||||
}
|
||||
if (stderr.includes("EADDRINUSE")) {
|
||||
return { reason: "port-in-use", port: parseConflictingPort(stderr) };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function writeJson(filePath, value) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULTS = { defaultConnectionOrigin: "local" };
|
||||
const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" };
|
||||
|
||||
function getDesktopSettings() {
|
||||
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const path = require("path");
|
||||
|
||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||
|
||||
// Extensions the OS would run rather than open in an editor. With no editor
|
||||
// configured the temp copy goes to shell.openPath, and its name comes from
|
||||
// the remote server.
|
||||
const LAUNCHABLE_EXTENSIONS = new Set([
|
||||
// Windows
|
||||
"application",
|
||||
"appref-ms",
|
||||
"bat",
|
||||
"chm",
|
||||
"cmd",
|
||||
"com",
|
||||
"cpl",
|
||||
"exe",
|
||||
"gadget",
|
||||
"hta",
|
||||
"inf",
|
||||
"jar",
|
||||
"js",
|
||||
"jse",
|
||||
"library-ms",
|
||||
"lnk",
|
||||
"msc",
|
||||
"msi",
|
||||
"msp",
|
||||
"mst",
|
||||
"pif",
|
||||
"ps1",
|
||||
"psd1",
|
||||
"psm1",
|
||||
"py",
|
||||
"pyw",
|
||||
"reg",
|
||||
"scf",
|
||||
"scr",
|
||||
"search-ms",
|
||||
"settingcontent-ms",
|
||||
"url",
|
||||
"vbe",
|
||||
"vbs",
|
||||
"ws",
|
||||
"wsf",
|
||||
"wsh",
|
||||
"xll",
|
||||
// macOS
|
||||
"app",
|
||||
"command",
|
||||
"pkg",
|
||||
"terminal",
|
||||
"tool",
|
||||
"workflow",
|
||||
// Linux
|
||||
"appimage",
|
||||
"desktop",
|
||||
"run",
|
||||
"sh",
|
||||
]);
|
||||
|
||||
function isLaunchableFileName(fileName) {
|
||||
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
||||
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
||||
const extension = path.extname(name).slice(1).toLowerCase();
|
||||
return LAUNCHABLE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The login URL a server hands the desktop app goes to shell.openExternal,
|
||||
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
|
||||
* would launch whatever OS handler is registered for it.
|
||||
*/
|
||||
function isExternalLoginUrl(value) {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === "https:" || protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-privileged port for the loopback callback listener. */
|
||||
function isCallbackPort(value) {
|
||||
return Number.isInteger(value) && value >= 1024 && value <= 65535;
|
||||
}
|
||||
|
||||
module.exports = { isExternalLoginUrl, isCallbackPort };
|
||||
@@ -39,7 +39,11 @@ function resolveLocalShell(
|
||||
* how many helpers were fixed.
|
||||
*/
|
||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
||||
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
|
||||
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
||||
const dir = ptyDir.replace(
|
||||
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
||||
"$1.asar.unpacked",
|
||||
);
|
||||
const candidates = [
|
||||
path.join(dir, "build", "Release", "spawn-helper"),
|
||||
path.join(
|
||||
|
||||
@@ -29,12 +29,18 @@ const pty = require("node-pty");
|
||||
const WebSocket = require("ws");
|
||||
const linkedServer = require("./linked-server.cjs");
|
||||
const desktopSettings = require("./desktop-settings.cjs");
|
||||
const autoUpdates = require("./auto-updater.cjs");
|
||||
const { launchNativeRdp } = require("./native-rdp.cjs");
|
||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||
const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
||||
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
||||
const {
|
||||
EXTERNAL_EDITOR_SETTING,
|
||||
isLaunchableFileName,
|
||||
} = require("./external-editor.cjs");
|
||||
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||
|
||||
const localTerminalSessions = new Map();
|
||||
|
||||
@@ -699,6 +705,9 @@ let tray = null;
|
||||
let isQuitting = false;
|
||||
const tempFiles = new Map();
|
||||
const externalEditorSessions = new Map();
|
||||
// Paths the user picked in an open dialog this session. Only these may be
|
||||
// saved as the external editor, so the renderer cannot name an executable.
|
||||
const dialogChosenPaths = new Set();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||
@@ -1662,6 +1671,12 @@ ipcMain.handle("get-embedded-server-status", () => {
|
||||
ipcMain.handle(
|
||||
"external-browser-login",
|
||||
async (_event, authUrl, callbackPort) => {
|
||||
if (!isExternalLoginUrl(authUrl)) {
|
||||
return { success: false, error: "Invalid authentication URL" };
|
||||
}
|
||||
if (!isCallbackPort(callbackPort)) {
|
||||
return { success: false, error: "Invalid callback port" };
|
||||
}
|
||||
const http = require("http");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -1752,6 +1767,65 @@ ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
||||
return desktopSettings.saveDesktopSettings(settings);
|
||||
});
|
||||
|
||||
let appUpdater = null;
|
||||
|
||||
function startAppUpdater() {
|
||||
const kind = autoUpdates.getInstallKind({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
isPackaged: app.isPackaged,
|
||||
mas: !!process.mas,
|
||||
env: process.env,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath,
|
||||
exists: (target) => fs.existsSync(target),
|
||||
readFile: (target) => fs.readFileSync(target, "utf8"),
|
||||
});
|
||||
logToFile("Updater install kind:", kind);
|
||||
appUpdater = autoUpdates.createUpdater({
|
||||
currentVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
kind,
|
||||
getMode: () => desktopSettings.getDesktopSettings().updateMode,
|
||||
saveMode: (mode) =>
|
||||
desktopSettings.saveDesktopSettings({ updateMode: mode }),
|
||||
fetchReleases: async () => {
|
||||
const { net } = require("electron");
|
||||
const response = await net.fetch(autoUpdates.RELEASES_API, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub releases returned ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
loadAutoUpdater: () => require("electron-updater").autoUpdater,
|
||||
send: (state) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("desktop-update-state", state);
|
||||
}
|
||||
},
|
||||
log: logToFile,
|
||||
beforeInstall: () => {
|
||||
isQuitting = true;
|
||||
},
|
||||
});
|
||||
appUpdater.start();
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
"desktop-update-get-state",
|
||||
() => appUpdater?.getState() ?? null,
|
||||
);
|
||||
ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null);
|
||||
ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null);
|
||||
ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false);
|
||||
ipcMain.handle(
|
||||
"desktop-update-set-mode",
|
||||
(_event, mode) => appUpdater?.setMode(mode) ?? null,
|
||||
);
|
||||
|
||||
// The server an older version of the app was pointed at, offered again when
|
||||
// linking so upgraded installs do not have to look it up.
|
||||
ipcMain.handle("get-previous-server-url", () => {
|
||||
@@ -1924,6 +1998,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
// A proxy in front of the server may drop an idle WebSocket, which stopped
|
||||
// the tunnel. Pings count as traffic.
|
||||
function keepC2SSocketAlive(ws) {
|
||||
const timer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
}, 25_000);
|
||||
timer.unref?.();
|
||||
ws.once("close", () => clearInterval(timer));
|
||||
}
|
||||
|
||||
// C2S (client-to-server) tunnels relay through the server this desktop is
|
||||
// linked to, not the embedded backend.
|
||||
function getC2SRemoteBaseUrl() {
|
||||
@@ -2192,6 +2276,7 @@ async function openC2SRelay(
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
const pendingChunks = [];
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
@@ -2299,10 +2384,13 @@ async function testC2SRelay(
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
|
||||
if (runtime) runtime.authSocket = ws;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
// The relay sends its reason and then closes; keep the reason.
|
||||
let relayError = null;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -2339,6 +2427,10 @@ async function testC2SRelay(
|
||||
settle({ success: true });
|
||||
} else if (message.type === "error") {
|
||||
clearTimeout(timer);
|
||||
relayError = normalizeC2SErrorMessage(
|
||||
message.error,
|
||||
"Tunnel test failed",
|
||||
);
|
||||
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2352,7 +2444,10 @@ async function testC2SRelay(
|
||||
});
|
||||
ws.on("close", () => {
|
||||
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
||||
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
||||
setC2STunnelError(
|
||||
tunnel.name,
|
||||
relayError || "Endpoint SSH connection closed",
|
||||
);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
settle({ success: false, error: "Tunnel test connection closed" });
|
||||
@@ -2609,6 +2704,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
);
|
||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||
keepC2SSocketAlive(ws);
|
||||
const sockets = new Map();
|
||||
let closed = false;
|
||||
|
||||
@@ -3083,18 +3179,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
||||
return `[${date}] ${computerName} (${platform} ${release})`;
|
||||
});
|
||||
|
||||
function readSetting(key) {
|
||||
const settingsPath = path.join(app.getPath("userData"), "settings.json");
|
||||
if (!fs.existsSync(settingsPath)) return null;
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
}
|
||||
|
||||
ipcMain.handle("get-setting", (event, key) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
const settings = JSON.parse(settingsData);
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
return readSetting(key);
|
||||
} catch (error) {
|
||||
console.error("Error reading setting:", error);
|
||||
return null;
|
||||
@@ -3102,6 +3196,16 @@ ipcMain.handle("get-setting", (event, key) => {
|
||||
});
|
||||
|
||||
ipcMain.handle("set-setting", (event, key, value) => {
|
||||
if (typeof key !== "string") {
|
||||
return { success: false, error: "Invalid setting key" };
|
||||
}
|
||||
if (
|
||||
key === EXTERNAL_EDITOR_SETTING &&
|
||||
value &&
|
||||
!dialogChosenPaths.has(value)
|
||||
) {
|
||||
return { success: false, error: "Choose the editor in the file dialog" };
|
||||
}
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
@@ -3125,94 +3229,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
||||
try {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (!ses) return null;
|
||||
const cookies = await ses.cookies.get({
|
||||
name,
|
||||
...(targetUrl ? { url: targetUrl } : {}),
|
||||
});
|
||||
const cookie = cookies.find((candidate) =>
|
||||
cookieMatchesUrl(candidate, targetUrl),
|
||||
);
|
||||
return (
|
||||
cookie?.value ||
|
||||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
||||
null
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get session cookie:", error);
|
||||
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
||||
}
|
||||
});
|
||||
|
||||
function cookieMatchesUrl(cookie, targetUrl) {
|
||||
if (!targetUrl) return true;
|
||||
|
||||
try {
|
||||
const targetHost = new URL(targetUrl).hostname;
|
||||
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
||||
|
||||
return (
|
||||
cookieDomain === targetHost ||
|
||||
targetHost.endsWith(`.${cookieDomain}`) ||
|
||||
(!cookieDomain && targetHost === "localhost")
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
"wait-session-cookie",
|
||||
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (!ses) return { success: false, error: "No Electron session" };
|
||||
|
||||
const existingCookies = await ses.cookies.get({
|
||||
name,
|
||||
...(targetUrl ? { url: targetUrl } : {}),
|
||||
});
|
||||
const existingCookie = existingCookies.find((cookie) =>
|
||||
cookieMatchesUrl(cookie, targetUrl),
|
||||
);
|
||||
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
||||
return { success: true, value: existingCookie.value };
|
||||
}
|
||||
|
||||
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
||||
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
||||
return { success: true, value: rememberedCookie.value };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ses.cookies.off("changed", onCookieChanged);
|
||||
resolve({ success: false, error: "Timed out waiting for cookie" });
|
||||
}, timeoutMs);
|
||||
|
||||
function onCookieChanged(_event, cookie, _cause, removed) {
|
||||
if (
|
||||
removed ||
|
||||
cookie.name !== name ||
|
||||
!cookie.value ||
|
||||
cookie.value === previousValue ||
|
||||
!cookieMatchesUrl(cookie, targetUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
ses.cookies.off("changed", onCookieChanged);
|
||||
resolve({ success: true, value: cookie.value });
|
||||
}
|
||||
|
||||
ses.cookies.on("changed", onCookieChanged);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("clear-session-cookies", async () => {
|
||||
try {
|
||||
clearPersistedElectronAuthCookies();
|
||||
@@ -3340,7 +3356,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
|
||||
});
|
||||
|
||||
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
||||
return dialog.showOpenDialog(mainWindow, options || {});
|
||||
const result = await dialog.showOpenDialog(mainWindow, options || {});
|
||||
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
|
||||
return result;
|
||||
});
|
||||
|
||||
function toLocalFileType(stat) {
|
||||
@@ -3663,6 +3681,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
||||
|
||||
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||
try {
|
||||
// The editor comes from the saved setting, never from the caller.
|
||||
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
|
||||
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Choose an external editor to open this file type",
|
||||
};
|
||||
}
|
||||
|
||||
const result = createManagedTempFile(
|
||||
fileData?.fileName,
|
||||
fileData?.content,
|
||||
@@ -3684,10 +3711,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||
lastMtimeMs: stat.mtimeMs,
|
||||
});
|
||||
|
||||
const editorPath =
|
||||
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
||||
? fileData.editorPath.trim()
|
||||
: null;
|
||||
const openError = await openPathWithEditor(result.path, editorPath);
|
||||
if (openError) {
|
||||
closeExternalEditorSession(editId);
|
||||
@@ -3804,6 +3827,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
startAppUpdater();
|
||||
desktopSettings.removeOldSyncFiles();
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
@@ -26,6 +26,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getEmbeddedServerStatus: () =>
|
||||
ipcRenderer.invoke("get-embedded-server-status"),
|
||||
|
||||
// App Store builds are updated by the store.
|
||||
updates: process.mas
|
||||
? undefined
|
||||
: {
|
||||
getState: () => ipcRenderer.invoke("desktop-update-get-state"),
|
||||
check: () => ipcRenderer.invoke("desktop-update-check"),
|
||||
download: () => ipcRenderer.invoke("desktop-update-download"),
|
||||
install: () => ipcRenderer.invoke("desktop-update-install"),
|
||||
setMode: (mode) => ipcRenderer.invoke("desktop-update-set-mode", mode),
|
||||
onState: (callback) => {
|
||||
const listener = (_event, state) => callback(state);
|
||||
ipcRenderer.on("desktop-update-state", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("desktop-update-state", listener);
|
||||
},
|
||||
},
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
@@ -70,16 +87,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
|
||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||
getSessionCookie: (name, targetUrl) =>
|
||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
|
||||
ipcRenderer.invoke(
|
||||
"wait-session-cookie",
|
||||
name,
|
||||
targetUrl,
|
||||
previousValue,
|
||||
timeoutMs,
|
||||
),
|
||||
|
||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||
|
||||
@@ -361,10 +361,15 @@
|
||||
"group": "plugins",
|
||||
"description": "Use another plugin index instead of the official registry."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_COMMUNITY_REGISTRY_URL",
|
||||
"group": "plugins",
|
||||
"description": "Use another plugin index instead of the community registry. Its plugins still have to be signed with the community key."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||
"group": "plugins",
|
||||
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
|
||||
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||
@@ -442,6 +447,18 @@
|
||||
"internal": true,
|
||||
"description": "Read on Linux to pick the password store and window behavior."
|
||||
},
|
||||
{
|
||||
"name": "APPIMAGE",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by AppImage. Tells the desktop app it can update itself."
|
||||
},
|
||||
{
|
||||
"name": "FLATPAK_ID",
|
||||
"group": "desktop",
|
||||
"internal": true,
|
||||
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DESKTOP_SESSION",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -26,6 +26,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-updater": "^6.8.9",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"motion": "^13.1.1",
|
||||
@@ -106,6 +107,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.2.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
@@ -7589,7 +7591,6 @@
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
||||
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
@@ -9016,6 +9017,34 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-updater": {
|
||||
"version": "6.8.9",
|
||||
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
|
||||
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"builder-util-runtime": "9.7.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lazy-val": "^1.0.5",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"semver": "~7.7.3",
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
|
||||
@@ -9870,7 +9899,6 @@
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -10803,7 +10831,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
@@ -10869,7 +10896,6 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
@@ -11218,6 +11244,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -11230,6 +11262,13 @@
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
@@ -13020,7 +13059,6 @@
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
@@ -13780,6 +13818,12 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -14081,7 +14125,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
@@ -15015,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.0",
|
||||
"version": "26.10.1",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -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 && node scripts/check-env-catalog.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/check-sdk-guide.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",
|
||||
@@ -61,6 +61,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-updater": "^6.8.9",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"motion": "^13.1.1",
|
||||
@@ -141,6 +142,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.2.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.8
|
||||
|
||||
### Added
|
||||
|
||||
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||
|
||||
## 1.0.7
|
||||
|
||||
### Fixed
|
||||
|
||||
- A migration file, or a part of one, holding only comments made core fail to start the plugin
|
||||
|
||||
## 1.0.6
|
||||
|
||||
### Added
|
||||
|
||||
- `PLUGIN-GUIDE.md`, a guide to building, testing and releasing a plugin, written so AI coding tools can read it too
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.8 | Plugin API 1.2.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
Run `npm update @termix-ssh/plugin-sdk` to get the newest one.
|
||||
|
||||
If this guide and the SDK's TypeScript types disagree, the types are right. They live next to this file
|
||||
in `dist/*.d.ts`. The long form of everything here is at https://docs.termix.site/develop, and the raw
|
||||
Markdown of each page is at
|
||||
`https://raw.githubusercontent.com/Termix-SSH/Docs/main/develop/<page>.md` (for example
|
||||
`develop/backend.md`), which is easier for tools to read than the site.
|
||||
|
||||
## What a plugin is
|
||||
|
||||
Termix is self-hosted, plugin-based server management. Core keeps hosts, credentials, users, sharing and
|
||||
the shell. Everything else, even the SSH terminal, is a plugin built with this SDK.
|
||||
|
||||
A plugin is:
|
||||
|
||||
- `manifest.json`: what it is, what it adds and what it may do. Termix reads it without running any code.
|
||||
- A backend, `src/backend/index.ts`, run inside the Termix server process. It exports `activate(ctx)`.
|
||||
- A frontend, `src/frontend/index.tsx`, run in the browser in the same page as Termix. It exports
|
||||
`activate(app)`.
|
||||
|
||||
There is no sandbox. Capabilities make a plugin honest and easy to audit, but code runs with the server's
|
||||
rights. Write it as if an admin will read every line, because reviewers do.
|
||||
|
||||
## Hard rules
|
||||
|
||||
Break one of these and the plugin fails to load, fails validation or gets rejected in review.
|
||||
|
||||
1. Import only `@termix-ssh/plugin-sdk/*`, your own files and npm packages. Never import Termix's own code.
|
||||
2. Create everything inside `activate`. No servers, timers, sockets or connections at the top of a module.
|
||||
Disable then enable runs `activate` again on the same module, and it must work without a restart.
|
||||
3. Clean up what you make yourself with `ctx.disposables.add(fn)` (backend) or `app.onDispose(fn)`
|
||||
(frontend). Things made through `ctx` and `app` are cleaned up for you.
|
||||
4. Never call `process.exit` or touch signals.
|
||||
5. Never open your own SSH connection. Use `ctx.ssh` so jump hosts, proxies, host keys, every auth type and
|
||||
sharing work the way the user set them up.
|
||||
6. Outbound HTTP goes through `ctx.fetch` (needs `network:outbound`), not a raw `fetch` or axios.
|
||||
7. Ask for the fewest capabilities you can. A call that needs one you didn't list throws
|
||||
`PluginCapabilityError`.
|
||||
8. Never take a user id from a request body or a service caller. Use `ctx.currentActor()`.
|
||||
9. Check permissions on the backend. Frontend checks only hide UI.
|
||||
10. Call `await ctx.db.persist()` after every database write. On SQLite a write without it can be lost on
|
||||
restart.
|
||||
11. Never use `.returning()` in a query. MySQL doesn't have it. Read a new row back by a key you chose.
|
||||
12. Never edit a migration that has shipped. Termix checks a checksum and refuses to start the plugin. Add a
|
||||
new one.
|
||||
13. Never index a `text()` or `json()` column. Use `varchar(n)` for anything you index.
|
||||
14. Never hard code UI text. Every string goes in `locales/en.json` and is read with `t()`.
|
||||
15. The plugin `id` never changes once released. It names your tables, routes and permissions.
|
||||
16. The version in `manifest.json`, `package.json` and the newest `CHANGELOG.md` section must match.
|
||||
17. Put an `@openapi` JSDoc block above every route.
|
||||
18. No obfuscated or minified source, and no code downloaded and run at runtime.
|
||||
19. No tracking, telemetry or network calls the user didn't ask for. If you need one, make it off by
|
||||
default and explain it in your docs.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
manifest.json what the plugin is, adds and may do
|
||||
package.json npm scripts and dependencies
|
||||
CHANGELOG.md release notes, one section per version
|
||||
README.md the repo front page
|
||||
AGENTS.md notes for AI tools working on this plugin
|
||||
docs/index.md the plugin's docs page (more pages next to it)
|
||||
src/backend/index.ts activate(ctx), runs in the Termix server
|
||||
src/backend/tables.ts table definitions, exports `tables`
|
||||
src/backend/routes.ts HTTP routes
|
||||
src/frontend/index.tsx activate(app), runs in the browser
|
||||
locales/en.json every string the plugin shows
|
||||
locales/translated/ other languages, like de_DE.json (optional)
|
||||
migrations/ sqlite/, postgres/, mysql/ and snapshot.json, made by npm run migrations
|
||||
tests/backend/ backend tests (Node)
|
||||
tests/frontend/ frontend tests (jsdom)
|
||||
vitest.config.ts uses the SDK preset
|
||||
.github/workflows/ ci.yml and release.yml, calling shared workflows in Termix-Registry
|
||||
dist/ build output, never commit it
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # termix-plugin build: dist/backend.js, dist/frontend.js, CSS, openapi.json
|
||||
npm test # termix-plugin test: Vitest
|
||||
npm run typecheck # tsc
|
||||
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
|
||||
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
|
||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
|
||||
npm run format # Prettier
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
## Done checklist
|
||||
|
||||
Before you call a change finished:
|
||||
|
||||
- [ ] `npm test` passes
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run validate` passes with no warnings you can fix
|
||||
- [ ] `npm run build` passes
|
||||
- [ ] `npm run format:check` passes
|
||||
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
||||
- [ ] New strings are in `locales/en.json`
|
||||
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
||||
- [ ] New env vars are listed in `env` in the manifest
|
||||
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
||||
- [ ] User facing changes are in `CHANGELOG.md` under the version they will ship in
|
||||
|
||||
## Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json",
|
||||
"id": "hello",
|
||||
"name": "Hello",
|
||||
"version": "1.0.0",
|
||||
"description": "Says hello and keeps a few notes.",
|
||||
"author": { "name": "You", "url": "https://example.com" },
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/you/termix-plugin-hello",
|
||||
"docs": "https://github.com/you/termix-plugin-hello/tree/main/docs",
|
||||
"category": "Productivity",
|
||||
"icon": "Hand",
|
||||
"features": ["A tab from the sidebar", "Notes per user"],
|
||||
"engine": { "termix": ">=26.10.0", "api": "^1.2" },
|
||||
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||
"env": [],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "hello",
|
||||
"titleKey": "tab.title",
|
||||
"icon": "Hand",
|
||||
"openFrom": ["rail"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
```
|
||||
|
||||
Unknown fields are refused at every level, so a typo fails validation. The schema at
|
||||
`node_modules/@termix-ssh/plugin-sdk/schema/manifest.schema.json` has every field.
|
||||
|
||||
| Field | Notes |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Lowercase, starts with a letter, 2 to 40 characters, letters, numbers and dashes. Can't be an official plugin's id. |
|
||||
| `version` | Semver. Betas are `X.Y.Z-beta.N`. |
|
||||
| `category` | One of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. |
|
||||
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
||||
| `features` | Up to 20 short lines shown on the plugin's page. |
|
||||
| `docs` | An https link to your docs. |
|
||||
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
|
||||
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
||||
| `capabilities` | What your code may do. See the reference at the end. |
|
||||
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
||||
| `provides`, `requires` | Services you offer and use. |
|
||||
| `env` | Every environment variable your backend reads. `validate` warns about unlisted `process.env.X`. Prefer settings. |
|
||||
| `desktop` | `mirror` (default), `local` or `server`: how a linked desktop app treats the plugin. |
|
||||
| `nativeDependencies` | npm packages with `.node` bindings. Avoid them: community plugins can't count on them. |
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
|
||||
## Backend
|
||||
|
||||
```ts
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { notes } from "./tables.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const notesTable = await ctx.db.define(notes);
|
||||
registerRoutes(ctx.http.router<Router>(), ctx, notesTable);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything registered through ctx is disposed by core.
|
||||
}
|
||||
```
|
||||
|
||||
Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own files use `.js`.
|
||||
|
||||
### What ctx has
|
||||
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
|
||||
### The actor
|
||||
|
||||
In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()` returns it. Background
|
||||
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
|
||||
that user's permissions.
|
||||
|
||||
### Routes
|
||||
|
||||
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
|
||||
your own port or an nginx change. Before your handler runs, Termix checks sign in, sets the actor, answers
|
||||
503 if the plugin is off, parses JSON up to 2 MB and catches errors.
|
||||
|
||||
```ts
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/hello/notes:
|
||||
* post:
|
||||
* summary: Add a note for the signed-in user
|
||||
* tags:
|
||||
* - Hello
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* text:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Note added
|
||||
* 400:
|
||||
* description: Missing or empty text
|
||||
*/
|
||||
router.post("/notes", async (req, res) => {
|
||||
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
||||
if (!text) return res.status(400).json({ error: "text is required" });
|
||||
const db = await ctx.db.client<BetterSQLite3Database>();
|
||||
await db
|
||||
.insert(notesTable)
|
||||
.values({ userId: ctx.currentActor() as string, text });
|
||||
await ctx.db.persist();
|
||||
res.status(201).json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||
`dist/openapi.json` and the docs site renders them.
|
||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
||||
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
||||
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
||||
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||
`conn.isDataUnlocked()` per message.
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
|
||||
### Errors
|
||||
|
||||
Errors from routes, sockets, listeners and timers count against an error budget. Five in a minute and
|
||||
Termix turns the plugin off (state `failed`). A 4xx you send on purpose doesn't count. Validate input and
|
||||
send 400s instead of throwing.
|
||||
|
||||
### SSH
|
||||
|
||||
```ts
|
||||
const { client, dispose } = await ctx.ssh.connect(hostId, {
|
||||
purpose: "hello",
|
||||
profile: "background",
|
||||
});
|
||||
try {
|
||||
// use the ssh2 client
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
```
|
||||
|
||||
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
|
||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||
|
||||
### Events, timers, alerts, services
|
||||
|
||||
```ts
|
||||
ctx.events.on("host.deleted", ({ hostId }) => cleanUp(hostId)); // core topics need events:core
|
||||
ctx.events.emit("plugin.hello.greeted", { name }); // your own: plugin.<id>.<name>
|
||||
ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
||||
```
|
||||
|
||||
- Alerts go through `ctx.notify.send` (`notify:send`). Never build your own notification system.
|
||||
- Offer a service: declare it in `provides` with a version and a permission, then
|
||||
`ctx.services.provide("hello.greetings", impl)`. The name starts with your id. Methods read the caller
|
||||
from `ctx.currentActor()`.
|
||||
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
|
||||
nothing while no provider runs, so always handle that.
|
||||
|
||||
### Shared packages
|
||||
|
||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
|
||||
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
|
||||
|
||||
## Database
|
||||
|
||||
Needs `db:own`. Define tables once in TypeScript, generate migrations for SQLite, PostgreSQL and MySQL,
|
||||
query with [Drizzle](https://orm.drizzle.team/).
|
||||
|
||||
```ts
|
||||
// src/backend/tables.ts
|
||||
import {
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
export const notes = defineTable(
|
||||
"notes",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
text: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_p_hello_notes_user_id", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
export const tables = [notes]; // termix-plugin migrations reads this
|
||||
```
|
||||
|
||||
The table is stored as `p_<id with dashes as underscores>_notes`. Index names should use the same prefix.
|
||||
|
||||
| Builder | Type |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `id()` | Auto increment key |
|
||||
| `text()` | Any length string. Not indexable |
|
||||
| `varchar(n)` | String up to `n` (255 default). Indexable |
|
||||
| `integer()`, `bigint()`, `real()`, `boolean()` | Numbers and booleans |
|
||||
| `timestamp()` | A time, stored as text |
|
||||
| `json()` | JSON stored as text. Not indexable |
|
||||
| `encryptedText()` | Text encrypted with the owning user's key |
|
||||
| `refUser()` | A user id. Rows are deleted with the user and included in their data export |
|
||||
| `refHost()` | A host id. Rows are deleted with the host |
|
||||
|
||||
Modifiers: `.notNull()`, `.unique()`, `.primaryKey()`, `.default(value)`, `.defaultNow()`.
|
||||
|
||||
After any change to `tables.ts`, run `npm run migrations` and read the three new files. Renames and type
|
||||
changes are refused rather than guessed: write those by hand, for all three databases. A migration may only
|
||||
touch your own tables. `createTestDb` applies them in tests, so a broken one fails `npm test`.
|
||||
|
||||
Query through `const db = await ctx.db.client()`, then `await ctx.db.persist()` after writes
|
||||
(`persist({ lazy: true })` for frequent low value writes). `ctx.db.dialect` says which database runs.
|
||||
|
||||
Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secrets` for secrets,
|
||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||
|
||||
## Frontend
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useTranslation,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { EmptyState, PanelShell } from "@termix-ssh/plugin-sdk/ui";
|
||||
import { Hand } from "lucide-react";
|
||||
|
||||
function HelloTab(_props: TabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell icon={<Hand className="size-4" />} title={t("tab.title")}>
|
||||
<EmptyState title={t("tab.empty")} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp) {
|
||||
app.registerRailItem({
|
||||
id: "hello",
|
||||
icon: Hand,
|
||||
titleKey: "tab.title",
|
||||
kind: "tab",
|
||||
permission: "use",
|
||||
});
|
||||
app.registerTab("hello", HelloTab, {
|
||||
icon: Hand,
|
||||
titleKey: "tab.title",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Every `register*` returns an undo function, and everything is undone on disable. Each component runs in
|
||||
your plugin's error boundary and Suspense.
|
||||
|
||||
| Member | What it does |
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `registerRailItem` | A sidebar rail button |
|
||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
||||
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||
| `registerHostAction` | A host button, palette entry or default connect |
|
||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||
| `registerHostProtocol` | A connection protocol next to SSH |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
||||
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
||||
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
||||
| `registerSettingsComponent` | A component for a `custom` settings field |
|
||||
| `registerAction`, `invokeAction` | Actions across plugins |
|
||||
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||
| `registerOnboardingStep` | A first run setup step |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||
| `onSettingsChanged`, `onDispose` | Hooks |
|
||||
|
||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
|
||||
|
||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||
your own host settings, under `host.pluginSettings[<id>]`.
|
||||
|
||||
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
|
||||
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
|
||||
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
|
||||
https://docs.termix.site/develop/more-extension-points.
|
||||
|
||||
## Looking native
|
||||
|
||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||
exports with props is `ui.api.txt` in the Termix repo
|
||||
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
||||
|
||||
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
||||
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
||||
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
||||
- Small type: most text 11 to 13px.
|
||||
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
|
||||
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
|
||||
desktop layout.
|
||||
- Tailwind classes work. `build` compiles the ones you use against Termix's theme.
|
||||
- Link to your docs with `DocsLink` or `PanelShell`'s `docs` prop, never a hard coded URL.
|
||||
|
||||
## Settings
|
||||
|
||||
Declare them in `contributes.settings` and Termix draws the form, stores values, encrypts secrets and
|
||||
checks saves. You don't build settings UI.
|
||||
|
||||
- Scopes: `admin` (for everyone), `user` (each person), `host` (a section in the host editor, with an
|
||||
`enableKey` switch first).
|
||||
- Types: `boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`, `json`, `custom`.
|
||||
- Options: `labelKey`, `descriptionKey`, `placeholderKey`, `default`, `options`, `min`, `max`, `requires`,
|
||||
`group`, `permission`, `hidden`, `component`.
|
||||
- Read: `ctx.settings.get(key)`, `getUser(userId, key)`, `getHost(hostId, key)`. Write: `set`, `setHost`.
|
||||
React: `ctx.settings.onChange`. Check: `ctx.settings.onValidate("admin", values => ({ key: "message" }))`.
|
||||
- Frontend: `useSettings("user")` gives values and `save`.
|
||||
|
||||
Prefer settings to environment variables. Use an env var only for something needed before Termix starts.
|
||||
|
||||
## Permissions
|
||||
|
||||
Declare short names in `contributes.permissions`. Termix registers them as `<id>.<name>`. `defaultRoles`
|
||||
gives them to the built in `admin` and `user` roles on first install. You can't claim a core group
|
||||
(`hosts`, `credentials`, `admin`) or another plugin's. Labels are locale keys.
|
||||
|
||||
## Translations
|
||||
|
||||
Every string goes in `locales/en.json`, nested by area. Manifest keys (`titleKey`, `labelKey`) and `t()`
|
||||
read it from your own namespace. Unknown keys fall back to Termix's shared strings like `common.save`.
|
||||
Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go in
|
||||
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
|
||||
Untranslated strings show in English.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
|
||||
it. More pages sit next to it with `title` and `order` front matter. Plain Markdown, no MDX. Images go in
|
||||
`docs/img/`.
|
||||
|
||||
Don't write by hand what is generated: features, settings, permissions, capabilities, env vars, services,
|
||||
the API reference (from `@openapi`) and the changelog. Write short sentences in plain words and name
|
||||
buttons exactly as the UI does.
|
||||
|
||||
## Auth and protocols
|
||||
|
||||
Login methods, second factors and SSH auth types need `auth:provide`, which is high risk and reviewed
|
||||
closely. Declare ids in `contributes.auth` and register them with `ctx.auth.registerLoginMethod`,
|
||||
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
||||
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
||||
|
||||
## Testing
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
```
|
||||
|
||||
Backend tests go in `tests/backend/` (Node), frontend tests in `tests/frontend/` (jsdom). Never next to the
|
||||
source.
|
||||
|
||||
Backend, from `@termix-ssh/plugin-sdk/testing`:
|
||||
|
||||
```ts
|
||||
const db = await createTestDb(pluginDir); // in memory SQLite, your migrations, stub users/ssh_data/roles
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities, // calls outside these throw, like the real thing
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: ["hello.use"], // leave out to allow everything
|
||||
});
|
||||
await activate(mock.ctx);
|
||||
mock.setActor("user-1");
|
||||
```
|
||||
|
||||
`createMockCtx` also takes `hosts` and `services`. `createFakeContext()` is a looser double that skips the
|
||||
capability checks. The template's `tests/backend/routes.test.ts` serves the router with Express and calls
|
||||
it with `fetch`: start from it.
|
||||
|
||||
Frontend:
|
||||
|
||||
```tsx
|
||||
import { renderWithApp } from "@termix-ssh/plugin-sdk/testing";
|
||||
import * as plugin from "../../src/frontend/index.js";
|
||||
|
||||
const app = await renderWithApp(plugin, {
|
||||
api: { get: async () => ({ data: [] }) },
|
||||
});
|
||||
expect(app.registered.tabs()).toContain("hello");
|
||||
```
|
||||
|
||||
Test every route, with and without the permission. Test that disable then enable works, and that the plugin
|
||||
works when an optional dependency is missing.
|
||||
|
||||
## Running it on a server
|
||||
|
||||
1. You need Node 24 and a Termix server (26.10 or newer) you are an admin on. A local Docker one is fine.
|
||||
2. In Termix, open Settings, General and turn on Plugin developer mode.
|
||||
3. Make an API key in Settings, API keys.
|
||||
4. Run `npx termix-plugin dev --server http://localhost:30001 --key tmx_...` (or set `TERMIX_SERVER_URL`
|
||||
and `TERMIX_API_KEY`). It builds, installs and reinstalls on every save. Reload the page for frontend
|
||||
changes. `--once` installs once.
|
||||
|
||||
Or `npm run build && npm run pack` and install the `.tmxplug` from Plugins, Install from file. Dev installs
|
||||
are marked unverified. You can't use the id of a plugin that ships with Termix, and a registry copy of the
|
||||
same plugin must be uninstalled first.
|
||||
|
||||
## Releasing
|
||||
|
||||
Plugins use semver. Work happens on a `dev-X.Y.Z` branch made from `main`, named after the version it will
|
||||
ship as. `main` only changes when a stable release merges a dev branch into it.
|
||||
|
||||
`CHANGELOG.md`, newest first, no `## Unreleased` section and no dates:
|
||||
|
||||
```md
|
||||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Added
|
||||
|
||||
- A wave button in the terminal toolbar
|
||||
|
||||
### Fixed
|
||||
|
||||
- Notes keep their order after a restart
|
||||
```
|
||||
|
||||
Sections: `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security`.
|
||||
`termix-plugin changelog --version 1.1.0` prints one version's notes.
|
||||
|
||||
Run the Release workflow by hand from the dev branch on GitHub:
|
||||
|
||||
| Choice | What it does |
|
||||
| --------- | --------------------------------------------------------------------------------- |
|
||||
| stable | Tests, builds and publishes `X.Y.Z`, merges the branch into `main` and deletes it |
|
||||
| beta | Publishes `X.Y.Z-beta.N`, N counts up on its own |
|
||||
| overwrite | Releases the manifest version again from this commit |
|
||||
| dry-run | Builds and packs, publishes nothing |
|
||||
|
||||
No secrets are needed. The workflow records GitHub build provenance for the `.tmxplug`, and the run summary
|
||||
shows the `sha256` for a registry submission. If a dev branch changes `.github/workflows`, add a
|
||||
`TERMIX_PAT` secret (a token with `repo` and `workflow` scopes). To also sign with your own key, set
|
||||
`TERMIX_PLUGIN_SIGNING_KEY`. Keep `.github/workflows/release.yml` as the template has it: the community
|
||||
registry checks provenance from it.
|
||||
|
||||
## Submitting to the community registry
|
||||
|
||||
Before you submit:
|
||||
|
||||
- The repo is public with an open source license, made from the template or using its `release.yml`.
|
||||
- `npm test`, `npm run typecheck`, `npm run validate` and `npm run build` pass.
|
||||
- The `id` isn't used by an official plugin or another submission.
|
||||
- `name`, `description`, `author`, `repository` (your repo) and `docs` (or a README that explains use) are
|
||||
set.
|
||||
|
||||
Then release a stable or beta version and fork https://github.com/Termix-SSH/Termix-Registry. Add
|
||||
`community/plugins/<id>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "../../community-submission-schema.json",
|
||||
"id": "my-plugin",
|
||||
"repository": "https://github.com/you/termix-plugin-my-plugin",
|
||||
"maintainers": ["your-github-username"],
|
||||
"versions": [
|
||||
{ "version": "1.0.0", "sha256": "<from the release run summary>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Open a pull request. The Check submission job downloads the `v<version>` release and checks the `sha256`,
|
||||
that the manifest inside has your id, version and repo, that it has build provenance from the release
|
||||
workflow, and that you are a maintainer. Then a person reviews the source at the tag and merges it. The
|
||||
registry signs it with the community key and lists it a few minutes later.
|
||||
|
||||
Reviewers check that:
|
||||
|
||||
- The source does what the name and description say, and nothing else.
|
||||
- Nothing is obfuscated or minified, and no code is downloaded and run at runtime.
|
||||
- There are no network calls, tracking or telemetry the user didn't ask for.
|
||||
- Capabilities and permissions are the fewest needed.
|
||||
- `package.json` scripts and dependencies don't fetch or run anything unexpected at install or build.
|
||||
- Secrets are only used for what the plugin is for.
|
||||
- The name and icon don't pretend to be an official plugin.
|
||||
|
||||
Every version needs its own PR that adds it to the top of `versions`. Overwriting a reviewed release drops
|
||||
it from the registry until a new version is submitted. Admins see the plugin once they turn on the community
|
||||
registry in Plugins, Registries.
|
||||
|
||||
To run your own registry instead, see https://docs.termix.site/develop/registries.
|
||||
|
||||
## termix-plugin CLI
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` |
|
||||
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
||||
| `test` | Apply patches, run Vitest |
|
||||
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
||||
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
||||
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `changelog` | Print one version's notes. `--version x.y.z` |
|
||||
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
||||
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
||||
|
||||
## SDK entry points
|
||||
|
||||
| Import | What it is |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
||||
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
||||
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
||||
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
||||
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
||||
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
||||
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
||||
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
||||
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
|
||||
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Risk | What it allows |
|
||||
| -------------------- | -------- | ------------------------------------------------------ |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Run background work as another user, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames and roles |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Tests fail after `npm install`: usually a missing devDependency (`@testing-library/dom`, `jsdom`,
|
||||
`better-sqlite3`) or a newer TypeScript major pulled in by another package. Pin `typescript` to the
|
||||
template's range.
|
||||
- `hello-world`, `hello_world` or `Hello World` left over from the template in `package.json`, locales,
|
||||
index names, API paths, tab ids or tests.
|
||||
- Changed `tables.ts` without running `npm run migrations`, or kept the template's `migrations/` folder
|
||||
after renaming the id. Delete it and run `npm run migrations` once on a fresh plugin.
|
||||
- A rail item or tab id that isn't declared in `contributes`.
|
||||
- A string typed straight into JSX instead of `t()`.
|
||||
- Forgetting `await ctx.db.persist()` after a write.
|
||||
- Frontend changes not showing during `dev`: reload the page.
|
||||
- An id clash with an official plugin: dev installs and registry submissions are refused.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- Docs: https://docs.termix.site/develop
|
||||
- Official plugins are all open source and built the same way. Plugin-Workspaces is a small one to start
|
||||
from, Plugin-Docker runs commands on hosts, Plugin-Snippets offers a service, Plugin-Automations uses
|
||||
services, Plugin-SSO and Plugin-TOTP add sign in, Plugin-Vault adds an SSH auth type and
|
||||
Plugin-Remote-Desktop adds a protocol. All at https://github.com/Termix-SSH.
|
||||
- Help: the Discord at https://discord.gg/jVQGdvHDrf. SDK bugs go in
|
||||
https://github.com/Termix-SSH/Termix/issues.
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.svg" width="120" height="120" alt="Termix Logo" />
|
||||
<img src="https://termix.site/img/logo.svg" width="120" alt="Termix Logo" />
|
||||
|
||||
<h1>Termix Plugin SDK</h1>
|
||||
|
||||
@@ -50,7 +50,7 @@ in place of the flags. Reload the page to pick up frontend changes.
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
|
||||
@@ -58,4 +58,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.
|
||||
Apache License 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE).
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.8",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -103,6 +103,7 @@
|
||||
"tsconfig.plugin.json",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"PLUGIN-GUIDE.md",
|
||||
"client.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
|
||||
* (multer, a raw body for signature checking, a proxied stream).
|
||||
*/
|
||||
rawBody?: boolean;
|
||||
/**
|
||||
* Requests per minute one address may make to this plugin's public routes.
|
||||
* Defaults to 120, which a proxied page loading its own scripts and images
|
||||
* can pass on its own. Capped at 10000, and an admin's
|
||||
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
|
||||
*/
|
||||
publicRateLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
/** False for a chunk that is only comments, which a driver refuses to run. */
|
||||
function hasSql(chunk: string): boolean {
|
||||
return (
|
||||
chunk
|
||||
.replace(/--[^\n]*/g, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/** DROP statement for a definition, for removePluginData. */
|
||||
export function dropTableSql(
|
||||
dialect: SqlDialect,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"only-arches": ["x86_64", "aarch64"],
|
||||
"skip-icons-check": false,
|
||||
"skip-appstream-check": false
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 610 B |
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 593 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 574 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 20 KiB |
@@ -7,7 +7,7 @@
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
||||
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
|
||||
* folder of plugin repos like ../Plugins. A plugin there whose sources
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* packages/plugin-sdk/PLUGIN-GUIDE.md ships with the SDK and is what plugin
|
||||
* authors and their AI tools read first. Fails when it misses a CLI command,
|
||||
* a capability or an entry point, or when its header names an older SDK or
|
||||
* plugin API version, so the guide is touched with every SDK release.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
||||
|
||||
// Exports that are plumbing, not something a plugin author imports.
|
||||
const INTERNAL_EXPORTS = new Set([
|
||||
"./testing/setup",
|
||||
"./testing-host",
|
||||
"./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json",
|
||||
"./theme.css",
|
||||
]);
|
||||
|
||||
function cliCommands(source) {
|
||||
const block = source.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
|
||||
if (!block) return [];
|
||||
return [...block[1].matchAll(/^ {2}([a-z-]+)[:,]/gm)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
function capabilityIds(source) {
|
||||
return [
|
||||
...new Set(
|
||||
[...source.matchAll(/entry\("([a-z-]+:[a-z-]+)"/g)].map((m) => m[1]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function entryPoints(pkg) {
|
||||
return Object.keys(pkg.exports ?? {})
|
||||
.filter((key) => key !== "." && !INTERNAL_EXPORTS.has(key))
|
||||
.map((key) => `@termix-ssh/plugin-sdk/${key.slice(2)}`);
|
||||
}
|
||||
|
||||
function apiVersion(source) {
|
||||
return source.match(/PLUGIN_API_VERSION = "([^"]+)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function findProblems({
|
||||
guide,
|
||||
commands,
|
||||
capabilities,
|
||||
entries,
|
||||
sdkVersion,
|
||||
api,
|
||||
}) {
|
||||
const problems = [];
|
||||
for (const command of commands) {
|
||||
// `sign <file>` counts as well as `sign`.
|
||||
if (!new RegExp(`\`${command}[\` ]`).test(guide))
|
||||
problems.push(`CLI command "${command}" is not covered`);
|
||||
}
|
||||
for (const id of capabilities) {
|
||||
if (!guide.includes(`\`${id}\``))
|
||||
problems.push(`capability "${id}" is not covered`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!guide.includes(`\`${entry}\``))
|
||||
problems.push(`entry point "${entry}" is not covered`);
|
||||
}
|
||||
const header = guide.match(/^SDK (\S+) \| Plugin API (\S+)$/m);
|
||||
if (!header) {
|
||||
problems.push('missing the "SDK x.y.z | Plugin API x.y.z" header line');
|
||||
} else {
|
||||
if (header[1] !== sdkVersion) {
|
||||
problems.push(`header says SDK ${header[1]}, package is ${sdkVersion}`);
|
||||
}
|
||||
if (api && header[2] !== api) {
|
||||
problems.push(`header says Plugin API ${header[2]}, SDK has ${api}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const read = (file) => fs.readFileSync(path.join(SDK, file), "utf8");
|
||||
const pkg = JSON.parse(read("package.json"));
|
||||
const problems = findProblems({
|
||||
guide: read("PLUGIN-GUIDE.md"),
|
||||
commands: cliCommands(read("cli/index.mjs")),
|
||||
capabilities: capabilityIds(read("src/capabilities.ts")),
|
||||
entries: entryPoints(pkg),
|
||||
sdkVersion: pkg.version,
|
||||
api: apiVersion(read("src/manifest.ts")),
|
||||
});
|
||||
if (!pkg.files?.includes("PLUGIN-GUIDE.md")) {
|
||||
problems.push(
|
||||
'"PLUGIN-GUIDE.md" is not in the SDK package.json files list',
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
console.error(
|
||||
"check-sdk-guide: update packages/plugin-sdk/PLUGIN-GUIDE.md",
|
||||
);
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
cliCommands,
|
||||
capabilityIds,
|
||||
entryPoints,
|
||||
apiVersion,
|
||||
findProblems,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { cliCommands, capabilityIds, entryPoints, apiVersion, findProblems } =
|
||||
require("./check-sdk-guide.cjs") as {
|
||||
cliCommands: (source: string) => string[];
|
||||
capabilityIds: (source: string) => string[];
|
||||
entryPoints: (pkg: { exports?: Record<string, unknown> }) => string[];
|
||||
apiVersion: (source: string) => string | null;
|
||||
findProblems: (input: {
|
||||
guide: string;
|
||||
commands: string[];
|
||||
capabilities: string[];
|
||||
entries: string[];
|
||||
sdkVersion: string;
|
||||
api: string | null;
|
||||
}) => string[];
|
||||
};
|
||||
|
||||
const GUIDE = [
|
||||
"# Guide",
|
||||
"",
|
||||
"SDK 1.2.3 | Plugin API 1.4.0",
|
||||
"",
|
||||
"| `build` | builds |",
|
||||
"| `sign <file>` | signs |",
|
||||
"| `db:own` | tables |",
|
||||
"| `@termix-ssh/plugin-sdk/db` | tables |",
|
||||
].join("\n");
|
||||
|
||||
const input = {
|
||||
guide: GUIDE,
|
||||
commands: ["build", "sign"],
|
||||
capabilities: ["db:own"],
|
||||
entries: ["@termix-ssh/plugin-sdk/db"],
|
||||
sdkVersion: "1.2.3",
|
||||
api: "1.4.0",
|
||||
};
|
||||
|
||||
describe("readers", () => {
|
||||
it("reads command names from the COMMANDS map", () => {
|
||||
const source = [
|
||||
"const COMMANDS = {",
|
||||
" build,",
|
||||
" patch: async () => {},",
|
||||
" test: async (options) => {",
|
||||
" return run(options);",
|
||||
" },",
|
||||
" verify,",
|
||||
"};",
|
||||
].join("\n");
|
||||
expect(cliCommands(source)).toEqual(["build", "patch", "test", "verify"]);
|
||||
});
|
||||
|
||||
it("reads capability ids once each", () => {
|
||||
const source =
|
||||
'entry("db:own", "low"),\nentry("ssh:connect", "high"),\nentry("db:own", "low")';
|
||||
expect(capabilityIds(source)).toEqual(["db:own", "ssh:connect"]);
|
||||
});
|
||||
|
||||
it("skips the root and internal exports", () => {
|
||||
expect(
|
||||
entryPoints({
|
||||
exports: {
|
||||
".": {},
|
||||
"./db": {},
|
||||
"./testing/setup": {},
|
||||
"./theme.css": "",
|
||||
},
|
||||
}),
|
||||
).toEqual(["@termix-ssh/plugin-sdk/db"]);
|
||||
});
|
||||
|
||||
it("reads the plugin API version", () => {
|
||||
expect(apiVersion('export const PLUGIN_API_VERSION = "1.2.0";')).toBe(
|
||||
"1.2.0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProblems", () => {
|
||||
it("passes a guide that covers everything", () => {
|
||||
expect(findProblems(input)).toEqual([]);
|
||||
});
|
||||
|
||||
it("names what is missing", () => {
|
||||
expect(
|
||||
findProblems({
|
||||
...input,
|
||||
commands: ["build", "dev"],
|
||||
capabilities: ["db:own", "kv:own"],
|
||||
entries: ["@termix-ssh/plugin-sdk/ui"],
|
||||
}),
|
||||
).toEqual([
|
||||
'CLI command "dev" is not covered',
|
||||
'capability "kv:own" is not covered',
|
||||
'entry point "@termix-ssh/plugin-sdk/ui" is not covered',
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when the header is behind the SDK", () => {
|
||||
expect(
|
||||
findProblems({ ...input, sdkVersion: "1.2.4", api: "1.5.0" }),
|
||||
).toEqual([
|
||||
"header says SDK 1.2.3, package is 1.2.4",
|
||||
"header says Plugin API 1.4.0, SDK has 1.5.0",
|
||||
]);
|
||||
expect(findProblems({ ...input, guide: "no header" })).toContain(
|
||||
'missing the "SDK x.y.z | Plugin API x.y.z" header line',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* and options.
|
||||
*
|
||||
* Every target builds the SDK and stages plugins (local builds from
|
||||
* ../Termix-Plugins when it exists), then:
|
||||
* ../Plugins when it exists), then:
|
||||
*
|
||||
* - web: compiles the backend in watch mode, starts it, then starts Vite. A
|
||||
* backend or SDK change restarts the backend. A plugin change rebuilds that
|
||||
@@ -652,9 +652,7 @@ async function main() {
|
||||
const pluginsDir = options.local
|
||||
? path.resolve(
|
||||
root,
|
||||
options.pluginsDir ??
|
||||
process.env.TERMIX_LOCAL_PLUGINS ??
|
||||
"../Termix-Plugins",
|
||||
options.pluginsDir ?? process.env.TERMIX_LOCAL_PLUGINS ?? "../Plugins",
|
||||
)
|
||||
: null;
|
||||
const localPlugins =
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const updates = require("../electron/auto-updater.cjs") as {
|
||||
channelFile: (channel: string, platform: string, arch: string) => string;
|
||||
getInstallKind: (input: Record<string, unknown>) => string;
|
||||
resolveRelease: (
|
||||
releases: unknown,
|
||||
opts: { currentVersion: string; platform: string; arch: string },
|
||||
) => {
|
||||
tag: string;
|
||||
version: string;
|
||||
channel: string;
|
||||
feedUrl: string;
|
||||
releaseUrl: string;
|
||||
} | null;
|
||||
createUpdater: (deps: Record<string, unknown>) => {
|
||||
getState: () => Record<string, unknown>;
|
||||
check: () => Promise<Record<string, unknown>>;
|
||||
download: () => Promise<Record<string, unknown>>;
|
||||
install: () => boolean;
|
||||
setMode: (mode: string) => Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function release(tag: string, assets: string[], extra = {}) {
|
||||
return {
|
||||
tag_name: tag,
|
||||
draft: false,
|
||||
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/${tag}`,
|
||||
assets: assets.map((name) => ({ name })),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL = [
|
||||
"latest.yml",
|
||||
"latest-mac.yml",
|
||||
"latest-linux.yml",
|
||||
"latest-linux-arm64.yml",
|
||||
"latest-linux-arm.yml",
|
||||
"beta.yml",
|
||||
"beta-mac.yml",
|
||||
"beta-linux.yml",
|
||||
];
|
||||
|
||||
const win = { platform: "win32", arch: "x64" };
|
||||
|
||||
describe("channelFile", () => {
|
||||
it("matches the names electron-builder writes", () => {
|
||||
expect(updates.channelFile("latest", "win32", "x64")).toBe("latest.yml");
|
||||
expect(updates.channelFile("latest", "darwin", "arm64")).toBe(
|
||||
"latest-mac.yml",
|
||||
);
|
||||
expect(updates.channelFile("latest", "linux", "x64")).toBe(
|
||||
"latest-linux.yml",
|
||||
);
|
||||
expect(updates.channelFile("beta", "linux", "arm64")).toBe(
|
||||
"beta-linux-arm64.yml",
|
||||
);
|
||||
expect(updates.channelFile("latest", "linux", "arm")).toBe(
|
||||
"latest-linux-arm.yml",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRelease", () => {
|
||||
it("picks the newest stable release and points the feed at its tag", () => {
|
||||
const found = updates.resolveRelease(
|
||||
[release("v26.10.2", ALL), release("v26.10.3", ALL)],
|
||||
{ currentVersion: "26.10.1", ...win },
|
||||
);
|
||||
expect(found).toMatchObject({
|
||||
tag: "v26.10.3",
|
||||
version: "26.10.3",
|
||||
channel: "latest",
|
||||
feedUrl:
|
||||
"https://github.com/Termix-SSH/Termix/releases/download/v26.10.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores SDK, 2.9.x patch and draft releases", () => {
|
||||
expect(
|
||||
updates.resolveRelease(
|
||||
[
|
||||
release("sdk-v1.0.9", ALL),
|
||||
release("release-2.9.4-tag", ALL),
|
||||
release("v26.11.0", ALL, { draft: true }),
|
||||
],
|
||||
{ currentVersion: "26.10.1", ...win },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing is newer", () => {
|
||||
expect(
|
||||
updates.resolveRelease([release("v26.10.1", ALL)], {
|
||||
currentVersion: "26.10.1",
|
||||
...win,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps stable installs off betas", () => {
|
||||
expect(
|
||||
updates.resolveRelease([release("v26.11.0-beta.1", ALL)], {
|
||||
currentVersion: "26.10.1",
|
||||
...win,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("moves a beta install to the next beta, then to stable", () => {
|
||||
expect(
|
||||
updates.resolveRelease(
|
||||
[release("v26.11.0-beta.1", ALL), release("v26.11.0-beta.2", ALL)],
|
||||
{ currentVersion: "26.11.0-beta.1", ...win },
|
||||
),
|
||||
).toMatchObject({ tag: "v26.11.0-beta.2", channel: "beta" });
|
||||
expect(
|
||||
updates.resolveRelease(
|
||||
[release("v26.11.0-beta.2", ALL), release("v26.11.0", ALL)],
|
||||
{ currentVersion: "26.11.0-beta.2", ...win },
|
||||
),
|
||||
).toMatchObject({ tag: "v26.11.0", channel: "latest" });
|
||||
});
|
||||
|
||||
it("waits while the newest release is still uploading", () => {
|
||||
expect(
|
||||
updates.resolveRelease(
|
||||
[release("v26.10.2", ALL), release("v26.10.3", ["termix.exe"])],
|
||||
{ currentVersion: "26.10.1", ...win },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("needs this platform's channel file", () => {
|
||||
const releases = [release("v26.10.2", ["latest.yml", "latest-mac.yml"])];
|
||||
expect(
|
||||
updates.resolveRelease(releases, {
|
||||
currentVersion: "26.10.1",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
updates.resolveRelease(releases, {
|
||||
currentVersion: "26.10.1",
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("handles junk input", () => {
|
||||
expect(
|
||||
updates.resolveRelease(null, { currentVersion: "26.10.1", ...win }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
updates.resolveRelease([release("v26.10.2", ALL)], {
|
||||
currentVersion: "dev",
|
||||
...win,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInstallKind", () => {
|
||||
const base = {
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
isPackaged: true,
|
||||
mas: false,
|
||||
env: {},
|
||||
execPath: path.join("C:", "Program Files", "Termix", "Termix.exe"),
|
||||
resourcesPath: "/opt/Termix/resources",
|
||||
exists: () => false,
|
||||
readFile: () => {
|
||||
throw new Error("missing");
|
||||
},
|
||||
};
|
||||
|
||||
it("disables dev builds and leaves the App Store alone", () => {
|
||||
expect(updates.getInstallKind({ ...base, isPackaged: false })).toBe(
|
||||
"disabled",
|
||||
);
|
||||
expect(
|
||||
updates.getInstallKind({ ...base, platform: "darwin", mas: true }),
|
||||
).toBe("store");
|
||||
});
|
||||
|
||||
it("updates NSIS installs and only links MSI, portable and 32-bit", () => {
|
||||
const uninstaller = path.join(
|
||||
path.dirname(base.execPath),
|
||||
"Uninstall Termix.exe",
|
||||
);
|
||||
const nsis = (target: string) => target === uninstaller;
|
||||
expect(updates.getInstallKind({ ...base, exists: nsis })).toBe("auto");
|
||||
expect(updates.getInstallKind(base)).toBe("manual");
|
||||
expect(
|
||||
updates.getInstallKind({ ...base, arch: "ia32", exists: nsis }),
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("updates DMG installs unless run from the mounted image", () => {
|
||||
const mac = { ...base, platform: "darwin", arch: "arm64" };
|
||||
expect(
|
||||
updates.getInstallKind({
|
||||
...mac,
|
||||
execPath: "/Applications/Termix.app/Contents/MacOS/Termix",
|
||||
}),
|
||||
).toBe("auto");
|
||||
expect(
|
||||
updates.getInstallKind({
|
||||
...mac,
|
||||
execPath: "/Volumes/Termix/Termix.app/Contents/MacOS/Termix",
|
||||
}),
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("tells Linux installs apart", () => {
|
||||
const linux = { ...base, platform: "linux" };
|
||||
expect(updates.getInstallKind({ ...linux, env: { FLATPAK_ID: "x" } })).toBe(
|
||||
"manual",
|
||||
);
|
||||
expect(
|
||||
updates.getInstallKind({
|
||||
...linux,
|
||||
exists: (target: string) => target === "/.flatpak-info",
|
||||
}),
|
||||
).toBe("manual");
|
||||
expect(
|
||||
updates.getInstallKind({ ...linux, env: { APPIMAGE: "/tmp/a" } }),
|
||||
).toBe("auto");
|
||||
const dpkgOwned = (target: string) =>
|
||||
target === "/var/lib/dpkg/info/termix.list";
|
||||
expect(
|
||||
updates.getInstallKind({
|
||||
...linux,
|
||||
exists: dpkgOwned,
|
||||
readFile: () => "deb\n",
|
||||
}),
|
||||
).toBe("auto");
|
||||
// The AUR package ships the deb's files without dpkg.
|
||||
expect(updates.getInstallKind({ ...linux, readFile: () => "deb\n" })).toBe(
|
||||
"manual",
|
||||
);
|
||||
expect(updates.getInstallKind(linux)).toBe("manual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUpdater", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function fakeAutoUpdater() {
|
||||
const emitter = new EventEmitter() as EventEmitter &
|
||||
Record<string, unknown>;
|
||||
emitter.setFeedURL = vi.fn();
|
||||
emitter.checkForUpdates = vi.fn(async () => {
|
||||
emitter.emit("update-available", { version: "26.10.2" });
|
||||
});
|
||||
emitter.downloadUpdate = vi.fn(async () => {
|
||||
emitter.emit("update-downloaded", { version: "26.10.2" });
|
||||
});
|
||||
emitter.quitAndInstall = vi.fn();
|
||||
return emitter;
|
||||
}
|
||||
|
||||
function setup(kind = "auto", mode = "prompt") {
|
||||
const auto = fakeAutoUpdater();
|
||||
const saved: string[] = [];
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const beforeInstall = vi.fn();
|
||||
const updater = updates.createUpdater({
|
||||
currentVersion: "26.10.1",
|
||||
...win,
|
||||
kind,
|
||||
getMode: () => mode,
|
||||
saveMode: (next: string) => saved.push(next),
|
||||
fetchReleases: async () => [release("v26.10.2", ALL)],
|
||||
loadAutoUpdater: () => auto,
|
||||
send: (state: Record<string, unknown>) => sent.push(state),
|
||||
log: () => {},
|
||||
beforeInstall,
|
||||
});
|
||||
return { auto, updater, saved, sent, beforeInstall };
|
||||
}
|
||||
|
||||
it("asks before downloading in prompt mode", async () => {
|
||||
const { auto, updater } = setup();
|
||||
const state = await updater.check();
|
||||
expect(auto.setFeedURL).toHaveBeenCalledWith({
|
||||
provider: "generic",
|
||||
url: "https://github.com/Termix-SSH/Termix/releases/download/v26.10.2",
|
||||
});
|
||||
expect(auto.autoDownload).toBe(false);
|
||||
expect(auto.allowDowngrade).toBe(false);
|
||||
expect(state).toMatchObject({ status: "available", version: "26.10.2" });
|
||||
|
||||
expect((await updater.download()).status).toBe("downloaded");
|
||||
expect(updater.install()).toBe(true);
|
||||
});
|
||||
|
||||
it("downloads on its own in automatic mode", async () => {
|
||||
const { auto, updater } = setup("auto", "automatic");
|
||||
await updater.check();
|
||||
expect(auto.autoDownload).toBe(true);
|
||||
expect(updater.getState().status).toBe("downloading");
|
||||
});
|
||||
|
||||
it("marks the quit before installing so the tray lets go", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { auto, updater, beforeInstall } = setup();
|
||||
await updater.check();
|
||||
await updater.download();
|
||||
updater.install();
|
||||
expect(beforeInstall).toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
expect(auto.quitAndInstall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only links the release for installs that can not update", async () => {
|
||||
const { auto, updater } = setup("manual");
|
||||
const state = await updater.check();
|
||||
expect(auto.setFeedURL).not.toHaveBeenCalled();
|
||||
expect(state).toMatchObject({
|
||||
status: "available",
|
||||
releaseUrl: "https://github.com/Termix-SSH/Termix/releases/tag/v26.10.2",
|
||||
});
|
||||
expect(updater.install()).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for store and dev builds", async () => {
|
||||
for (const kind of ["store", "disabled"]) {
|
||||
const { auto, updater } = setup(kind);
|
||||
expect((await updater.check()).status).toBe("idle");
|
||||
expect(auto.setFeedURL).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves valid modes only and falls back to prompt", () => {
|
||||
const { updater, saved } = setup("auto", "bogus");
|
||||
expect(updater.getState().mode).toBe("prompt");
|
||||
updater.setMode("never");
|
||||
updater.setMode("sometimes");
|
||||
expect(saved).toEqual(["never"]);
|
||||
expect(updater.getState().mode).toBe("never");
|
||||
});
|
||||
|
||||
it("reports a failed lookup instead of throwing", async () => {
|
||||
const updater = updates.createUpdater({
|
||||
currentVersion: "26.10.1",
|
||||
...win,
|
||||
kind: "auto",
|
||||
getMode: () => "prompt",
|
||||
saveMode: () => {},
|
||||
fetchReleases: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
loadAutoUpdater: () => fakeAutoUpdater(),
|
||||
send: () => {},
|
||||
log: () => {},
|
||||
});
|
||||
expect(await updater.check()).toMatchObject({
|
||||
status: "error",
|
||||
error: "offline",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PRE_29_DATABASE_ERROR } from "../src/backend/database/db/upgrade-guard.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { classifyBackendFailure } =
|
||||
@@ -46,6 +47,16 @@ describe("classifyBackendFailure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies a 2.8 database the backend refused to open", () => {
|
||||
const stderr = `[ERROR] [SYSTEM] Failed to initialize backend services [op:startup_failed]
|
||||
Error: ${PRE_29_DATABASE_ERROR}
|
||||
at assertNotPre29Database (upgrade-guard.js:14:11)`;
|
||||
expect(classifyBackendFailure(stderr)).toEqual({
|
||||
reason: "old-database",
|
||||
port: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a crash for any other non-zero exit", () => {
|
||||
expect(classifyBackendFailure("TypeError: boom")).toEqual({
|
||||
reason: "crashed",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
||||
isLaunchableFileName: (fileName: unknown) => boolean;
|
||||
};
|
||||
|
||||
describe("Electron external editor", () => {
|
||||
it("refuses remote file names the OS would run instead of open", () => {
|
||||
for (const name of [
|
||||
"payload.exe",
|
||||
"run.BAT",
|
||||
"script.ps1",
|
||||
"a.exe. ",
|
||||
"a.cmd...",
|
||||
"../../evil.vbs",
|
||||
"start.command",
|
||||
"launch.desktop",
|
||||
"install.sh",
|
||||
]) {
|
||||
expect(isLaunchableFileName(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens ordinary text files", () => {
|
||||
for (const name of [
|
||||
"nginx.conf",
|
||||
"README.md",
|
||||
"Dockerfile",
|
||||
".bashrc",
|
||||
"config.json",
|
||||
"index.ts",
|
||||
"",
|
||||
undefined,
|
||||
]) {
|
||||
expect(isLaunchableFileName(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { isExternalLoginUrl, isCallbackPort } =
|
||||
require("../electron/external-login.cjs") as {
|
||||
isExternalLoginUrl: (value: unknown) => boolean;
|
||||
isCallbackPort: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
describe("Electron external login", () => {
|
||||
it.each([
|
||||
"https://idp.example.com/authorize?client_id=termix",
|
||||
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
|
||||
])("opens web login URLs: %s", (url) => {
|
||||
expect(isExternalLoginUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file:///C:/Windows/System32/calc.exe",
|
||||
"smb://attacker/share",
|
||||
"ms-msdt:/id PCWDiagnostic",
|
||||
"javascript:alert(1)",
|
||||
"not a url",
|
||||
undefined,
|
||||
])("refuses other schemes: %s", (url) => {
|
||||
expect(isExternalLoginUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the ports the login screen picks", () => {
|
||||
expect(isCallbackPort(17832)).toBe(true);
|
||||
expect(isCallbackPort(17931)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
|
||||
"refuses callback port %s",
|
||||
(port) => {
|
||||
expect(isCallbackPort(port)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never hands the session cookie to the renderer over IPC", () => {
|
||||
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
|
||||
expect(main).not.toContain(`"${channel}"`);
|
||||
expect(preload).not.toContain(`"${channel}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("launches only the editor saved from a file dialog", () => {
|
||||
expect(main).not.toContain("fileData?.editorPath");
|
||||
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
||||
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||
});
|
||||
|
||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||
expect(main).toContain('"sync-proxy-config"');
|
||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||
expect(main).toContain("linkedServer.answerLogin(");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects setting keys that coerce to the protected editor key", async () => {
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const { vi } = await import("vitest");
|
||||
const writeFileSync = vi.fn();
|
||||
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
|
||||
runInNewContext(
|
||||
main.slice(
|
||||
main.indexOf('ipcMain.handle("set-setting"'),
|
||||
main.indexOf('ipcMain.handle("clear-session-cookies"'),
|
||||
),
|
||||
{
|
||||
ipcMain: {
|
||||
handle: (_: string, callback: typeof handler) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
|
||||
dialogChosenPaths: new Set(["/chosen/editor"]),
|
||||
app: { getPath: () => "/settings" },
|
||||
path: { join: (...parts: string[]) => parts.join("/") },
|
||||
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
|
||||
console,
|
||||
},
|
||||
);
|
||||
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
|
||||
expect(handler!({}, key, "/unapproved/editor")).toEqual({
|
||||
success: false,
|
||||
error: "Invalid setting key",
|
||||
});
|
||||
}
|
||||
expect(writeFileSync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
|
||||
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
|
||||
expect(
|
||||
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
|
||||
).toEqual({ success: true });
|
||||
expect(writeFileSync).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fixes electron-builder's update files (latest.yml and friends) after the
|
||||
* build, so the desktop updater gets the right download:
|
||||
*
|
||||
* --drop <text> remove entries whose file name contains text
|
||||
* (the ia32 installer, the combined Windows
|
||||
* installer, the universal mac zip)
|
||||
* --rename <old=new> follow an artifact the workflow renamed
|
||||
*
|
||||
* Usage: node scripts/fix-update-info.cjs [--drop x] [--rename a=b] files...
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
function fixUpdateInfo(info, { drop = [], rename = {} } = {}) {
|
||||
const rewrite = (name) => rename[name] ?? name;
|
||||
const seen = new Set();
|
||||
// electron-builder can list the same file more than once.
|
||||
const files = (info.files ?? [])
|
||||
.filter((file) => !drop.some((text) => file.url.includes(text)))
|
||||
.map((file) => ({ ...file, url: rewrite(file.url) }))
|
||||
.filter((file) => !seen.has(file.url) && seen.add(file.url));
|
||||
if (files.length === 0) {
|
||||
throw new Error("no files left in update info");
|
||||
}
|
||||
return { ...info, files, path: files[0].url, sha512: files[0].sha512 };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { drop: [], rename: {}, files: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--drop") options.drop.push(argv[++i]);
|
||||
else if (arg === "--rename") {
|
||||
const [from, to] = String(argv[++i]).split("=");
|
||||
if (!from || !to) throw new Error(`bad --rename ${argv[i]}`);
|
||||
options.rename[from] = to;
|
||||
} else options.files.push(arg);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
for (const file of options.files) {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const info = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
fs.writeFileSync(file, yaml.dump(fixUpdateInfo(info, options)));
|
||||
console.log(`Fixed ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fixUpdateInfo, parseArgs };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { fixUpdateInfo, parseArgs } = require("./fix-update-info.cjs");
|
||||
|
||||
const info = {
|
||||
version: "26.10.2",
|
||||
files: [
|
||||
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
|
||||
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
|
||||
],
|
||||
path: "termix_windows_ia32_nsis.exe",
|
||||
sha512: "a",
|
||||
releaseDate: "2026-10-11T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("fixUpdateInfo", () => {
|
||||
it("drops entries and points path at what is left", () => {
|
||||
const fixed = fixUpdateInfo(info, { drop: ["ia32"] });
|
||||
expect(fixed.files.map((f: { url: string }) => f.url)).toEqual([
|
||||
"termix_windows_x64_nsis.exe",
|
||||
]);
|
||||
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
|
||||
expect(fixed.sha512).toBe("b");
|
||||
expect(fixed.version).toBe("26.10.2");
|
||||
});
|
||||
|
||||
it("drops the combined Windows installer and duplicate entries", () => {
|
||||
const fixed = fixUpdateInfo(
|
||||
{
|
||||
...info,
|
||||
files: [
|
||||
{ url: "termix_windows_nsis.exe", sha512: "c", size: 3 },
|
||||
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
|
||||
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
|
||||
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
|
||||
],
|
||||
path: "termix_windows_nsis.exe",
|
||||
},
|
||||
{ drop: ["ia32", "termix_windows_nsis.exe"] },
|
||||
);
|
||||
expect(fixed.files).toEqual([
|
||||
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
|
||||
]);
|
||||
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
|
||||
});
|
||||
|
||||
it("follows renamed artifacts", () => {
|
||||
const fixed = fixUpdateInfo(
|
||||
{
|
||||
...info,
|
||||
files: [{ url: "termix_linux_x86_64_appimage.AppImage", sha512: "c" }],
|
||||
},
|
||||
{
|
||||
rename: {
|
||||
"termix_linux_x86_64_appimage.AppImage":
|
||||
"termix_linux_x64_appimage.AppImage",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(fixed.path).toBe("termix_linux_x64_appimage.AppImage");
|
||||
});
|
||||
|
||||
it("refuses to leave nothing to download", () => {
|
||||
expect(() => fixUpdateInfo(info, { drop: ["nsis"] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgs", () => {
|
||||
it("reads drops, renames and files", () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
"--drop",
|
||||
"ia32",
|
||||
"--rename",
|
||||
"a=b",
|
||||
"latest.yml",
|
||||
"beta.yml",
|
||||
]),
|
||||
).toEqual({
|
||||
drop: ["ia32"],
|
||||
rename: { a: "b" },
|
||||
files: ["latest.yml", "beta.yml"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,30 @@ const OUT = path.join(
|
||||
"settings-search-index.ts",
|
||||
);
|
||||
|
||||
function flatKeys(obj, prefix = "") {
|
||||
return Object.entries(obj).flatMap(([key, value]) =>
|
||||
typeof value === "object"
|
||||
? flatKeys(value, `${prefix}${key}.`)
|
||||
: [`${prefix}${key}`],
|
||||
);
|
||||
}
|
||||
|
||||
const EN_KEYS = new Set(
|
||||
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
|
||||
);
|
||||
|
||||
// t("x", { count }) reads x_one or x_other, so index the plural key.
|
||||
function realKey(key) {
|
||||
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
|
||||
? key
|
||||
: `${key}_other`;
|
||||
}
|
||||
|
||||
function keysIn(text) {
|
||||
return [
|
||||
...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])),
|
||||
...new Set(
|
||||
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -110,12 +131,18 @@ function adminSections() {
|
||||
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
||||
updates: keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
|
||||
"utf8",
|
||||
updates: [
|
||||
...new Set(
|
||||
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
||||
keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", file),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ Targets
|
||||
docker Build the production image and run it with your local plugins
|
||||
|
||||
Options
|
||||
--plugins <dir> Plugin repos to build and watch (default ../Termix-Plugins)
|
||||
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
|
||||
--no-local Use only the plugins pinned in docker/bundled-plugins.json
|
||||
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
|
||||
--guacd Run guacd in Docker for remote desktop
|
||||
|
||||
@@ -3,43 +3,45 @@ const path = require("node:path");
|
||||
|
||||
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
||||
|
||||
if (!fs.existsSync(nodePtyDir)) {
|
||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
||||
|
||||
const originalHelperRewrite = [
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
].join("\n");
|
||||
// Older installs may still carry the first version of this patch.
|
||||
const knownHelperRewrites = [
|
||||
[
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
].join("\n"),
|
||||
[
|
||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"}",
|
||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"}",
|
||||
].join("\n"),
|
||||
];
|
||||
|
||||
const patchedHelperRewrite = [
|
||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"}",
|
||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"}",
|
||||
].join("\n");
|
||||
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
|
||||
const patchedHelperRewrite =
|
||||
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
|
||||
|
||||
function patchUnixTerminalHelperRewrite() {
|
||||
if (!fs.existsSync(unixTerminalPath)) {
|
||||
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
|
||||
if (!fs.existsSync(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(unixTerminalPath, "utf8");
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (source.includes(patchedHelperRewrite)) {
|
||||
return false;
|
||||
}
|
||||
if (!source.includes(originalHelperRewrite)) {
|
||||
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
|
||||
if (!known) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
unixTerminalPath,
|
||||
source.replace(originalHelperRewrite, patchedHelperRewrite),
|
||||
file,
|
||||
source.replace(known, () => patchedHelperRewrite),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
|
||||
return fixed;
|
||||
}
|
||||
|
||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||
// Tests and afterPack require this file; only a direct run patches node_modules.
|
||||
function main() {
|
||||
if (!fs.existsSync(nodePtyDir)) {
|
||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (helperRewritePatched) {
|
||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||
|
||||
if (helperRewritePatched) {
|
||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||
}
|
||||
if (helpersChmodded > 0) {
|
||||
console.log(
|
||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||
);
|
||||
}
|
||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||
console.log("[patch-node-pty] Already patched or target files not found");
|
||||
}
|
||||
}
|
||||
if (helpersChmodded > 0) {
|
||||
console.log(
|
||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||
);
|
||||
}
|
||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||
console.log("[patch-node-pty] Already patched or target files not found");
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
||||
@@ -79,7 +79,6 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "./repositories/factory.js";
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||
import {
|
||||
selectExportableSettings,
|
||||
@@ -1235,227 +1234,218 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const credentialRepository =
|
||||
createCurrentCredentialRepository();
|
||||
const exists =
|
||||
await credentialRepository.existsForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
// The file's credential ids belong to the server that wrote it.
|
||||
const credentialIds = new Map<number, number>();
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const existingId =
|
||||
await credentialRepository.findIdForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (existingId !== null) {
|
||||
credentialIds.set(cred.id, existingId);
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"settings table not found in import file, skipping",
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
credentialIds.set(cred.id, Number(created.id));
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
},
|
||||
);
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: credentialIds.get(host.credential_id) ?? null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
credentialIds,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
@@ -1472,17 +1462,6 @@ app.post(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (req.file?.path && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file after error", {
|
||||
operation: "file_cleanup_error",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.error("SQLite import failed", error, {
|
||||
operation: "sqlite_import_api_failed",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
@@ -1491,6 +1470,17 @@ app.post(
|
||||
error: "Failed to import SQLite data",
|
||||
details: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
if (req.file) {
|
||||
try {
|
||||
fs.rmSync(req.file.path, { force: true });
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -2012,14 +2002,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
|
||||
|
||||
/**
|
||||
* A host's plugin protocol logins from an export. The file's credential ids
|
||||
* belong to the server that wrote it, so a login keeps one only when it is
|
||||
* a credential the importing user can use.
|
||||
* belong to the server that wrote it, so a login keeps one only when it maps
|
||||
* to a credential imported alongside it.
|
||||
*/
|
||||
async function importHostProtocolLogins(
|
||||
importDb: Database.Database,
|
||||
host: Record<string, unknown>,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
credentialIds: Map<number, number>,
|
||||
): Promise<void> {
|
||||
let rows: Array<Record<string, unknown>>;
|
||||
try {
|
||||
@@ -2040,7 +2031,7 @@ async function importHostProtocolLogins(
|
||||
}
|
||||
protocolAuth[String(row.protocol)] = {
|
||||
authType: row.auth_type,
|
||||
credentialId: row.credential_id,
|
||||
credentialId: credentialIds.get(row.credential_id as number) ?? null,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
fields,
|
||||
|
||||
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
plugin_id TEXT NOT NULL,
|
||||
registry_id TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, registry_id)
|
||||
);
|
||||
@@ -926,6 +925,38 @@ const dropRetiredColumns = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Registration checked for a taken username and then inserted with nothing
|
||||
// enforcing it, so concurrent sign-ups could create two accounts with one
|
||||
// name, and every lookup by username picked between them arbitrarily. Later
|
||||
// duplicates are renamed rather than deleted, so no account is lost.
|
||||
const enforceUniqueUsernames = () => {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
`);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to enforce unique usernames", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsColumns(sqlite);
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
@@ -997,6 +1028,7 @@ const migrateSchema = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
enforceUniqueUsernames();
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
@@ -1894,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
|
||||
}
|
||||
}
|
||||
|
||||
async function dropMysqlForeignKeys(
|
||||
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||
table: string,
|
||||
column: string,
|
||||
): Promise<void> {
|
||||
const keys = resultRows<{ name: string }>(
|
||||
await remote.execute(
|
||||
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
),
|
||||
);
|
||||
for (const key of keys) {
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||
* longer knows them. Dropped by hand, only where they still exist.
|
||||
@@ -1929,6 +1978,10 @@ async function dropRemoteRetired(
|
||||
for (const column of columns) {
|
||||
if (!existing.has(`${table}.${column}`)) continue;
|
||||
try {
|
||||
// Postgres drops a column's foreign keys with it; MySQL refuses.
|
||||
if (dialect === "mysql") {
|
||||
await dropMysqlForeignKeys(remote, table, column);
|
||||
}
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||
);
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*
|
||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
||||
* domain), so a user who never signed in to 2.9 still gets them.
|
||||
*
|
||||
* ssh_data.vault_profile_id goes because its foreign key follows the vault
|
||||
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
|
||||
* refuses every write to ssh_data and the other engines refuse the uninstall.
|
||||
*/
|
||||
|
||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"telnet_port",
|
||||
"tunnel_connections",
|
||||
"use_warpgate",
|
||||
"vault_profile_id",
|
||||
"vnc_port",
|
||||
"web_ui_config",
|
||||
"wol_broadcast_address",
|
||||
@@ -80,6 +84,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"donation_modal_dismissed",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
// 26.10.1 shows downloads only.
|
||||
plugin_install_counts: ["source"],
|
||||
user_preferences: [
|
||||
"command_autocomplete",
|
||||
"terminal_defaults",
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
export const users = mysqlTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = mysqlTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
@@ -1055,10 +1061,9 @@ 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
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = mysqlTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1072,6 @@ export const pluginInstallCounts = mysqlTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: int("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
@@ -1055,10 +1061,9 @@ 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
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = pgTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1072,6 @@ export const pluginInstallCounts = pgTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
@@ -1051,10 +1057,9 @@ 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
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = sqliteTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1063,7 +1068,6 @@ export const pluginInstallCounts = sqliteTable(
|
||||
pluginId: text("plugin_id").notNull(),
|
||||
registryId: text("registry_id").notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -103,11 +103,11 @@ export class CredentialRepository {
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
async findIdForImportIdentity(
|
||||
userId: string,
|
||||
name: string,
|
||||
username: string | null,
|
||||
): Promise<boolean> {
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
@@ -120,7 +120,7 @@ export class CredentialRepository {
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdForUser(
|
||||
|
||||
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
export interface PluginInstallCountInput {
|
||||
pluginId: string;
|
||||
count: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Popularity numbers for the store, one row per plugin and registry. */
|
||||
/** Download counts for the store, one row per plugin and registry. */
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const row = existing.get(entry.pluginId);
|
||||
if (row && row.count === entry.count && row.source === entry.source) {
|
||||
continue;
|
||||
}
|
||||
if (row && row.count === entry.count) continue;
|
||||
if (row) {
|
||||
await this.context.drizzle
|
||||
.update(pluginInstallCounts)
|
||||
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
||||
.set({ count: entry.count, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
|
||||
pluginId: entry.pluginId,
|
||||
registryId,
|
||||
count: entry.count,
|
||||
source: entry.source,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
|
||||
export interface SqliteForeignKeyClient {
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
export async function withSqliteForeignKeysDisabled<T>(
|
||||
sqlite: SqliteForeignKeyClient,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bulk import with foreign keys relaxed.
|
||||
*
|
||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
||||
* constraints have to stand down for the duration.
|
||||
*
|
||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
||||
* writes through repositories and handles individual row failures, so it must
|
||||
* still be allowed to run there; only SQLite needs this connection-local
|
||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
||||
*/
|
||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dialect = resolveDatabaseDialect();
|
||||
if (!needsExplicitPersist(dialect)) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||
}
|
||||
@@ -24,6 +24,27 @@ const userColumns = {
|
||||
),
|
||||
};
|
||||
|
||||
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
|
||||
const UNIQUE_VIOLATION_CODES = new Set([
|
||||
"SQLITE_CONSTRAINT_UNIQUE",
|
||||
"23505",
|
||||
"ER_DUP_ENTRY",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether creating a user failed because the username is taken. The id is
|
||||
* generated, so the username index is the only one a new row can collide on.
|
||||
* drizzle wraps driver errors, so the code may sit on a cause.
|
||||
*/
|
||||
export function isUsernameTaken(error: unknown): boolean {
|
||||
for (let e = error; e instanceof Error; e = e.cause) {
|
||||
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
|
||||
@@ -91,11 +91,11 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const key = line.slice(0, spaceIdx).toLowerCase();
|
||||
const value = line.slice(spaceIdx + 1).trim();
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2];
|
||||
|
||||
if (key === "host") {
|
||||
if (current && current.hostname) results.push(current);
|
||||
|
||||
@@ -17,8 +17,13 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
isLinkedDesktop,
|
||||
MAX_UPLOAD_BYTES,
|
||||
PluginManageError,
|
||||
} from "../../plugins/manage.js";
|
||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||
import { RegistryError } from "../../plugins/registries.js";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
@@ -236,6 +241,11 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -253,6 +263,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
const linked = await isLinkedDesktop();
|
||||
|
||||
// One query for every plugin rather than one per plugin.
|
||||
const grantsByPlugin = new Map<string, string[]>();
|
||||
@@ -285,6 +296,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -299,7 +311,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
env?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
};
|
||||
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
@@ -332,6 +346,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -357,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
features,
|
||||
env,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
@@ -380,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
if (!(await getLink())) return false;
|
||||
if (!(await isLinkedDesktop())) return false;
|
||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||
return isServerManaged(pluginId);
|
||||
}
|
||||
@@ -392,6 +406,7 @@ async function auditPluginAction(
|
||||
action: string,
|
||||
pluginId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
resourceType = "plugin",
|
||||
): Promise<void> {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
@@ -400,7 +415,7 @@ async function auditPluginAction(
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action,
|
||||
resourceType: "plugin",
|
||||
resourceType,
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: JSON.stringify(details),
|
||||
@@ -419,7 +434,7 @@ function sendManageError(
|
||||
fallback: string,
|
||||
operation: string,
|
||||
): void {
|
||||
if (error instanceof PluginManageError) {
|
||||
if (error instanceof PluginManageError || error instanceof RegistryError) {
|
||||
res.status(error.status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
@@ -486,7 +501,7 @@ router.get(
|
||||
* /plugins/onboarding/apply:
|
||||
* post:
|
||||
* summary: Apply the onboarding plugin picker
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
@@ -570,12 +585,310 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* get:
|
||||
* summary: List the plugin registries
|
||||
* description: The official registry, which is always on, the community registry and whether it is on, and every custom registry an admin added with its pinned key fingerprint.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
*/
|
||||
router.get(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { readRegistriesConfig } =
|
||||
await import("../../plugins/registries.js");
|
||||
const { officialSource, communitySource } =
|
||||
await import("../../plugins/registry-index.js");
|
||||
const config = await readRegistriesConfig();
|
||||
res.json({
|
||||
managedByServer: await isLinkedDesktop(),
|
||||
official: { id: "official", url: officialSource().url },
|
||||
community: {
|
||||
id: "community",
|
||||
url: communitySource().url,
|
||||
enabled: config.community.enabled,
|
||||
},
|
||||
custom: config.custom.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read plugin registries",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/preview:
|
||||
* post:
|
||||
* summary: Read a registry index before adding it
|
||||
* description: Fetches the index at url and returns its name, the id it would get, how many plugins it lists and the fingerprint of the key it signs with. Nothing is saved. The index must list a publicKey.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the registry would be added as.
|
||||
* 400:
|
||||
* description: Not an https URL, or the index has no publicKey.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: The registry is already added.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries/preview",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { previewRegistry } = await import("../../plugins/registries.js");
|
||||
res.json(await previewRegistry(req.body?.url));
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* post:
|
||||
* summary: Add a custom plugin registry
|
||||
* description: Reads the index again and pins the key it names. keyId must be the fingerprint the preview showed, otherwise the answer is 409 KEY_MISMATCH and nothing is saved. Every plugin installed from it must be signed by that key.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* - keyId
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* keyId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The added registry.
|
||||
* 400:
|
||||
* description: Not an https URL, no publicKey, or too many registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: Already added, or the key changed since the preview.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { addRegistry } = await import("../../plugins/registries.js");
|
||||
const entry = await addRegistry(req.body?.url, req.body?.keyId);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"add_plugin_registry",
|
||||
entry.id,
|
||||
{ url: entry.url, keyId: entry.keyId },
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to add the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* patch:
|
||||
* summary: Turn a plugin registry on or off
|
||||
* description: Works for community and custom registries. Plugins already installed from a registry that is off keep running but get no updates.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - enabled
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was changed.
|
||||
* 400:
|
||||
* description: Invalid body, or the official registry.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
*/
|
||||
router.patch(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { setRegistryEnabled } =
|
||||
await import("../../plugins/registries.js");
|
||||
await setRegistryEnabled(id, enabled);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin_registry" : "disable_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id, enabled });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to change the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* delete:
|
||||
* summary: Remove a custom plugin registry
|
||||
* description: Refused with 409 REGISTRY_IN_USE while plugins installed from it remain, since its key has to stay trusted for them to load.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was removed.
|
||||
* 400:
|
||||
* description: Official and community can only be turned off.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
* 409:
|
||||
* description: Plugins from it are still installed.
|
||||
*/
|
||||
router.delete(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { removeRegistry } = await import("../../plugins/registries.js");
|
||||
await removeRegistry(id);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"remove_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to remove the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
* patch:
|
||||
* summary: Enable or disable a plugin
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -597,6 +910,12 @@ router.post(
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* version:
|
||||
* type: string
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's new state.
|
||||
@@ -650,7 +969,26 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await setPluginState(pluginId, enabled);
|
||||
let result;
|
||||
if (enabled && (await isLinkedDesktop())) {
|
||||
const version = readVersion(req.body);
|
||||
const capabilities = readCapabilities(req.body);
|
||||
if (version === null || capabilities === null) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin version or capabilities" });
|
||||
return;
|
||||
}
|
||||
const { enablePluginWithConsent } =
|
||||
await import("../../plugins/manage.js");
|
||||
result = await enablePluginWithConsent(pluginId, {
|
||||
version,
|
||||
capabilities,
|
||||
userId: (req as AuthenticatedRequest).userId as string,
|
||||
});
|
||||
} else {
|
||||
result = await setPluginState(pluginId, enabled);
|
||||
}
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin" : "disable_plugin",
|
||||
@@ -1597,10 +1935,11 @@ function readVersion(body: unknown): string | undefined | null {
|
||||
* @openapi
|
||||
* /plugins/registry:
|
||||
* get:
|
||||
* summary: List the plugins in the official registry
|
||||
* summary: List the plugins in every registry that is on
|
||||
* description: >
|
||||
* Fetches the official registry index (cached for 15 minutes unless
|
||||
* refresh is set) and merges it with what is installed here: the
|
||||
* Fetches the index of every registry that is on (each cached for 15
|
||||
* minutes unless refresh is set), with each one's status under
|
||||
* registries, and merges them with what is installed here: the
|
||||
* installed version, the newest release this build can run, whether an
|
||||
* update asks for new capabilities, and the pin and auto-update choices.
|
||||
* tags:
|
||||
@@ -1741,10 +2080,10 @@ router.post(
|
||||
* @openapi
|
||||
* /plugins/{id}/install:
|
||||
* post:
|
||||
* summary: Install a plugin from the official registry
|
||||
* summary: Install a plugin from a registry
|
||||
* description: >
|
||||
* Downloads the release, checks its sha256 and signature against a key
|
||||
* this build trusts, loads it, grants the capabilities it declares and
|
||||
* Downloads the release, checks its sha256 and signature against the keys of
|
||||
* the registry it comes from, loads it, grants the capabilities it declares and
|
||||
* starts it along with any installed dependency that is off. Without a
|
||||
* version the newest compatible release is used; naming an older one
|
||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||
@@ -1774,6 +2113,9 @@ router.post(
|
||||
* type: string
|
||||
* enum: [stable, beta]
|
||||
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
|
||||
* registryId:
|
||||
* type: string
|
||||
* description: The registry to install from, official when unset. It must be turned on, and only the official registry can offer an official plugin id.
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -1786,9 +2128,9 @@ router.post(
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: Not in the official registry.
|
||||
* description: Not in that registry.
|
||||
* 409:
|
||||
* description: Already installed, incompatible, the consent does not match, or managed by a linked server.
|
||||
* description: Already installed, incompatible, the consent does not match, the registry is off, the id belongs to an official plugin, or managed by a linked server.
|
||||
* 502:
|
||||
* description: The registry could not be reached.
|
||||
*/
|
||||
@@ -1804,15 +2146,19 @@ router.post(
|
||||
req.body?.channel === undefined
|
||||
? "stable"
|
||||
: parsePluginChannel(req.body.channel);
|
||||
const registryId =
|
||||
req.body?.registryId === undefined ? undefined : req.body.registryId;
|
||||
if (
|
||||
!ID_PATTERN.test(pluginId) ||
|
||||
version === null ||
|
||||
capabilities === null ||
|
||||
channel === null
|
||||
channel === null ||
|
||||
(registryId !== undefined &&
|
||||
(typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin id, version, channel or capabilities" });
|
||||
res.status(400).json({
|
||||
error: "Invalid plugin id, version, channel, registry or capabilities",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
@@ -1822,11 +2168,13 @@ router.post(
|
||||
const result = await installPlugin(pluginId, {
|
||||
version,
|
||||
channel,
|
||||
registryId,
|
||||
userId,
|
||||
capabilities,
|
||||
});
|
||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||
version: result.version,
|
||||
registryId: registryId ?? "official",
|
||||
capabilities,
|
||||
});
|
||||
res.json(result);
|
||||
@@ -1847,7 +2195,7 @@ router.post(
|
||||
* post:
|
||||
* summary: Update or change the installed version of a plugin
|
||||
* description: >
|
||||
* Swaps in another release from the official registry, newer or older.
|
||||
* Swaps in another release from the registry the plugin came from, newer or older.
|
||||
* If the release declares capabilities the installed one does not, the
|
||||
* request must set acceptCapabilities and list exactly those added
|
||||
* capabilities in capabilities, otherwise it answers 409 with code
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
listUserSecondFactors,
|
||||
resetUserSecondFactors,
|
||||
} from "../../auth/second-factor-admin.js";
|
||||
import type {
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
type UserRepository,
|
||||
} from "../repositories/user-repository.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to admin-create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
|
||||
@@ -41,9 +41,13 @@ import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
} from "../repositories/user-repository.js";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
getTrustedProxySourceAddress,
|
||||
isTrustedProxyAddress,
|
||||
isTrustedProxyAuthEnabled,
|
||||
resolveTrustedProxyRoles,
|
||||
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
// Lost a race with a concurrent sign-up for the same name.
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
}
|
||||
if (!config.enabled) return res.json({ enabled: false });
|
||||
|
||||
const sourceAddress = req.socket.remoteAddress;
|
||||
const sourceAddress = getTrustedProxySourceAddress(req);
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
|
||||
@@ -263,28 +263,6 @@ export class SSHHostKeyVerifier {
|
||||
changeCount: host.hostKeyChangedCount || 0,
|
||||
});
|
||||
|
||||
if (isJumpHost) {
|
||||
await this.updateHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
host.hostKeyChangedCount || 0,
|
||||
);
|
||||
sshLogger.warn("Jump host key changed - auto-accepted", {
|
||||
operation: "host_key_updated",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
isJumpHost: true,
|
||||
});
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws) {
|
||||
sshLogger.error(
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
|
||||
@@ -86,7 +86,6 @@ export class SSHConnectionPool {
|
||||
private async createPooledClient(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
existing: PooledConnection[],
|
||||
): Promise<Client> {
|
||||
const generation = this.generations.get(key) || 0;
|
||||
this.pendingConnections.set(
|
||||
@@ -109,8 +108,11 @@ export class SSHConnectionPool {
|
||||
inUse: true,
|
||||
hostKey: key,
|
||||
};
|
||||
existing.push(pooled);
|
||||
this.connections.set(key, existing);
|
||||
// Re-read after the await: concurrent connects and removals may have
|
||||
// replaced this key's array in the meantime.
|
||||
const connections = this.connections.get(key) ?? [];
|
||||
connections.push(pooled);
|
||||
this.connections.set(key, connections);
|
||||
|
||||
client.on("end", () => {
|
||||
this.removeConnection(key, client);
|
||||
@@ -187,7 +189,7 @@ export class SSHConnectionPool {
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
else this.waiters.set(key, queue);
|
||||
clearTimeout(waiter.timer);
|
||||
this.createPooledClient(key, waiter.factory, connections)
|
||||
this.createPooledClient(key, waiter.factory)
|
||||
.then(waiter.resolve)
|
||||
.catch(waiter.reject);
|
||||
return;
|
||||
@@ -225,7 +227,7 @@ export class SSHConnectionPool {
|
||||
}
|
||||
|
||||
if (this.hasCapacity(key, connections)) {
|
||||
return this.createPooledClient(key, factory, connections);
|
||||
return this.createPooledClient(key, factory);
|
||||
}
|
||||
|
||||
return this.enqueueWaiter(key, factory);
|
||||
|
||||
@@ -1,50 +1,59 @@
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import * as tar from "tar";
|
||||
|
||||
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
|
||||
* disk. Returns null when the archive has no such file.
|
||||
* Reads every copy of one file out of a .tmxplug (a gzipped tar) without
|
||||
* unpacking it to disk. Uses the same tar parser as the loader's unpack, so
|
||||
* pax and long-name headers resolve the same way, and only plain files count
|
||||
* because they are the only kind the unpack writes. Names compare
|
||||
* case-insensitively since a case-insensitive disk folds them together.
|
||||
*/
|
||||
export function readArtifactFile(
|
||||
buffer: Buffer,
|
||||
wanted: string,
|
||||
): Buffer | null {
|
||||
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every((byte) => byte === 0)) break;
|
||||
const field = (start: number, length: number) =>
|
||||
header
|
||||
.subarray(start, start + length)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/s, "");
|
||||
const prefix = field(345, 155);
|
||||
const name = (
|
||||
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
|
||||
).replace(/^\.\//, "");
|
||||
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
|
||||
if (!Number.isFinite(size) || size < 0) break;
|
||||
const start = offset + 512;
|
||||
if (name === wanted) return tar.subarray(start, start + size);
|
||||
offset = start + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return null;
|
||||
export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] {
|
||||
const archive = zlib.gunzipSync(buffer, {
|
||||
maxOutputLength: MAX_UNPACKED_BYTES,
|
||||
});
|
||||
const key = wanted.toLowerCase();
|
||||
const found: Buffer[] = [];
|
||||
const parser = new tar.Parser({
|
||||
strict: true,
|
||||
onReadEntry: (entry) => {
|
||||
const name = path.posix.normalize(entry.path.replace(/\\/g, "/"));
|
||||
if (entry.type !== "File" || name.toLowerCase() !== key) {
|
||||
entry.resume();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
entry.on("end", () => found.push(Buffer.concat(chunks)));
|
||||
},
|
||||
});
|
||||
// The parser runs synchronously on an in-memory buffer.
|
||||
parser.end(archive);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
|
||||
/**
|
||||
* The manifest.json packed in a .tmxplug, parsed but not validated. An
|
||||
* archive with more than one is refused: the unpack keeps the last copy, so
|
||||
* whichever one this read the other could carry different capabilities.
|
||||
*/
|
||||
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
||||
let raw: Buffer | null;
|
||||
let copies: Buffer[];
|
||||
try {
|
||||
raw = readArtifactFile(buffer, "manifest.json");
|
||||
copies = readArtifactFiles(buffer, "manifest.json");
|
||||
} catch {
|
||||
throw new Error("the file is not a valid .tmxplug archive");
|
||||
}
|
||||
if (!raw) throw new Error("the archive has no manifest.json");
|
||||
if (copies.length === 0) throw new Error("the archive has no manifest.json");
|
||||
if (copies.length > 1) {
|
||||
throw new Error("the archive has more than one manifest.json");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString("utf8"));
|
||||
parsed = JSON.parse(copies[0].toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("manifest.json is not valid JSON");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The registry timer: refreshes the store's install counts and applies
|
||||
* The registry timer: refreshes the store's download counts and applies
|
||||
* plugin updates for plugins with auto-update on. An update that asks for a
|
||||
* new capability is never applied here; admins get an alert and review it in
|
||||
* the Plugins tab. Nothing is ever installed from here.
|
||||
@@ -13,8 +13,8 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
export async function runPluginAutoUpdate(): Promise<void> {
|
||||
const { syncInstallCounts } = await import("./install-counts.js");
|
||||
await syncInstallCounts();
|
||||
const { syncDownloadCounts } = await import("./download-counts.js");
|
||||
await syncDownloadCounts();
|
||||
|
||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||
if (await isLinkedDesktop()) return;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Download counts for the plugin store, copied from the registry's
|
||||
* stats.json into plugin_install_counts.
|
||||
*
|
||||
* A count is how often the plugin's .tmxplug release files were downloaded.
|
||||
* An instance that updates three times counts three times, and copies bundled
|
||||
* into Docker images or the desktop app never count. Treat it as a ranking,
|
||||
* never as a user count.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
fetchRegistryStats,
|
||||
officialSource,
|
||||
type RegistrySource,
|
||||
} from "./registry-index.js";
|
||||
|
||||
async function repository() {
|
||||
const { createCurrentPluginInstallCountRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginInstallCountRepository();
|
||||
}
|
||||
|
||||
const STALE_MS = 6 * 60 * 60_000;
|
||||
const FORCED_MS = 60_000;
|
||||
const WAIT_MS = 3_000;
|
||||
|
||||
let lastSyncAt = 0;
|
||||
let running: Promise<number> | null = null;
|
||||
|
||||
/**
|
||||
* Syncs when the counts are older than the registry timer's interval, or
|
||||
* older than a minute with force (the Refresh button). Waits a few seconds
|
||||
* at most so a slow registry never holds up the store; a sync that runs
|
||||
* longer still finishes in the background.
|
||||
*/
|
||||
export async function refreshDownloadCounts(force = false): Promise<void> {
|
||||
const age = Date.now() - lastSyncAt;
|
||||
if (!running && age >= (force ? FORCED_MS : STALE_MS)) {
|
||||
running = syncDownloadCounts().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
}
|
||||
if (!running) return;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
running,
|
||||
new Promise((resolve) => {
|
||||
timeout = setTimeout(resolve, WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetDownloadCountRefresh(): void {
|
||||
lastSyncAt = 0;
|
||||
running = null;
|
||||
}
|
||||
|
||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||
export async function syncDownloadCounts(): Promise<number> {
|
||||
lastSyncAt = Date.now();
|
||||
let changed = 0;
|
||||
let sources: RegistrySource[] = [officialSource()];
|
||||
try {
|
||||
const { listSources } = await import("./registries.js");
|
||||
sources = await listSources();
|
||||
} catch {
|
||||
// Official only when the registry choices cannot be read.
|
||||
}
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const stats = await fetchRegistryStats(source);
|
||||
if (!stats) continue;
|
||||
const entries = [...stats.plugins].map(([pluginId, downloads]) => ({
|
||||
pluginId,
|
||||
count: downloads,
|
||||
}));
|
||||
changed += await (await repository()).upsertMany(source.id, entries);
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not sync plugin download counts from ${source.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function readDownloadCounts(
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<Map<string, number>> {
|
||||
try {
|
||||
const rows = await (await repository()).listByRegistry(registryId);
|
||||
return new Map(rows.map((row) => [row.pluginId, row.count]));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* A plugin build leaves host packages (drizzle-orm, express, ssh2, ...) for
|
||||
* core to supply, and Node finds them by walking up from the importing file.
|
||||
* That works for bundled plugins inside the app, but a .tmxplug unpacked into
|
||||
* a data dir outside it (the desktop app keeps it in AppData) finds nothing.
|
||||
* Those imports fall back to core's own copy, the one bundled plugins get.
|
||||
*/
|
||||
|
||||
let installed = false;
|
||||
|
||||
export function isBareSpecifier(specifier: string): boolean {
|
||||
if (specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
||||
if (specifier.startsWith("#")) return false;
|
||||
// file:, node:, data:, and a Windows drive letter
|
||||
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
|
||||
}
|
||||
|
||||
/** Whether an import from parentURL may fall back to core's packages. */
|
||||
export function isUserPluginFile(
|
||||
parentURL: string | undefined,
|
||||
pluginsDir: string,
|
||||
): boolean {
|
||||
if (!parentURL?.startsWith("file:")) return false;
|
||||
let file: string;
|
||||
try {
|
||||
file = fileURLToPath(new URL(parentURL));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const relative = path.relative(path.resolve(pluginsDir), file);
|
||||
return (
|
||||
relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
export function installHostModuleFallback(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
// Node 22.15+. Older runtimes keep plain resolution.
|
||||
if (typeof module.registerHooks !== "function") return;
|
||||
module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
||||
!isBareSpecifier(specifier) ||
|
||||
!isUserPluginFile(context.parentURL, getPluginsDir())
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return nextResolve(specifier, {
|
||||
...context,
|
||||
parentURL: import.meta.url,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
|
||||
* Requests per minute one address may make to one plugin's public routes.
|
||||
* They run without a login, so they are the cheap way to hammer a plugin.
|
||||
*/
|
||||
function publicRateLimit(): number {
|
||||
const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
|
||||
|
||||
/** The admin's limit when set, else the plugin's own, else 120. */
|
||||
function publicRateLimit(pluginLimit?: number): number {
|
||||
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
|
||||
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||
}
|
||||
return 120;
|
||||
}
|
||||
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
|
||||
pluginId: string,
|
||||
ip: string,
|
||||
now = Date.now(),
|
||||
pluginLimit?: number,
|
||||
): boolean {
|
||||
if (publicHits.size > 10_000) {
|
||||
for (const [hitKey, entry] of publicHits) {
|
||||
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count > publicRateLimit();
|
||||
return entry.count > publicRateLimit(pluginLimit);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
@@ -319,7 +327,9 @@ export function createPluginRouter({
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPublicRequest(req, publicPaths, inner)) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
if (overPublicRateLimit(pluginId, ip)) {
|
||||
if (
|
||||
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
|
||||
) {
|
||||
res.status(429).json({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
|
||||