Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ac931468 |
No files matched your search
@@ -117,6 +117,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
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.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -134,7 +143,12 @@ jobs:
|
|||||||
- name: Build Windows (All Architectures)
|
- name: Build Windows (All Architectures)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 release/alpha.yml
|
||||||
|
|
||||||
- name: Upload Windows x64 NSIS Installer
|
- name: Upload Windows x64 NSIS Installer
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -178,6 +192,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
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
|
- name: Upload Windows x64 Portable
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
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-arm64-gnu
|
||||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
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.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -234,12 +268,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
DEBUG: electron-builder
|
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
|
- name: Build Linux arm64 and armv7l
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Rename Linux artifacts for consistency
|
||||||
run: |
|
run: |
|
||||||
@@ -255,6 +289,19 @@ jobs:
|
|||||||
|
|
||||||
cd ..
|
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
|
- name: Upload Linux x64 AppImage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||||
@@ -439,6 +486,15 @@ jobs:
|
|||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
node scripts/install-macos-sharp.cjs
|
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.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -494,7 +550,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
BUILD_VERSION="${{ github.run_number }}"
|
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
|
- name: Clean up MAS keychain before DMG build
|
||||||
if: steps.check_certs.outputs.has_certs == 'true'
|
if: steps.check_certs.outputs.has_certs == 'true'
|
||||||
@@ -553,7 +609,24 @@ jobs:
|
|||||||
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
|
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
|
||||||
npm run build
|
npm run build
|
||||||
fi
|
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
|
- name: Verify macOS sharp packaging
|
||||||
run: |
|
run: |
|
||||||
@@ -771,144 +844,6 @@ jobs:
|
|||||||
path: choco-build/*.nupkg
|
path: choco-build/*.nupkg
|
||||||
retention-days: 30
|
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:
|
submit-to-homebrew:
|
||||||
runs-on: blacksmith-6vcpu-macos-latest
|
runs-on: blacksmith-6vcpu-macos-latest
|
||||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
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
|
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
|
||||||
fi
|
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
|
- name: Upload artifacts to latest release
|
||||||
run: |
|
run: |
|
||||||
cd artifacts
|
find artifacts -type f ! -name '*.yml' -print0 | while IFS= read -r -d '' file; do
|
||||||
for dir in */; do
|
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||||
cd "$dir"
|
done
|
||||||
for file in *;
|
find artifacts -type f -name '*.yml' -print0 | while IFS= read -r -d '' file; do
|
||||||
do
|
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||||
if [ -f "$file" ]; then
|
|
||||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
cd ..
|
|
||||||
done
|
done
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -1050,6 +982,15 @@ jobs:
|
|||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
node scripts/install-macos-sharp.cjs
|
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
|
- name: Check for Code Signing Certificates
|
||||||
id: check_certs
|
id: check_certs
|
||||||
run: |
|
run: |
|
||||||
@@ -1188,7 +1129,7 @@ jobs:
|
|||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
run: |
|
run: |
|
||||||
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
|
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
|
- name: Verify macOS sharp packaging
|
||||||
if: steps.check_certs.outputs.has_certs == 'true'
|
if: steps.check_certs.outputs.has_certs == 'true'
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
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
|
- name: Build plugin SDK
|
||||||
run: npm run build:sdk
|
run: npm run build:sdk
|
||||||
|
|
||||||
|
|||||||
@@ -161,15 +161,62 @@ jobs:
|
|||||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||||
echo "prev_ref=$PREV" >> "$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]
|
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' }}
|
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout beta commit
|
- name: Checkout beta commit
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.sha }}
|
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -181,6 +228,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
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
|
- name: Build plugin SDK
|
||||||
run: npm run build:sdk
|
run: npm run build:sdk
|
||||||
|
|
||||||
@@ -197,7 +253,7 @@ jobs:
|
|||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
normalize:
|
normalize:
|
||||||
needs: [prep]
|
needs: [prep, translate]
|
||||||
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
@@ -284,8 +340,8 @@ jobs:
|
|||||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||||
|
|
||||||
merge-to-main:
|
merge-to-main:
|
||||||
needs: [prep, normalize, sync-main]
|
needs: [prep, translate, 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') }}
|
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
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
outputs:
|
outputs:
|
||||||
main_sha: ${{ steps.merge.outputs.main_sha }}
|
main_sha: ${{ steps.merge.outputs.main_sha }}
|
||||||
@@ -353,14 +409,14 @@ jobs:
|
|||||||
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
docker:
|
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') }}
|
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
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prep.outputs.version }}
|
version: ${{ needs.prep.outputs.version }}
|
||||||
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
|
||||||
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
|
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
|
secrets: inherit
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -418,7 +474,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create-beta-release:
|
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' }}
|
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
@@ -427,7 +483,7 @@ jobs:
|
|||||||
- name: Checkout beta commit
|
- name: Checkout beta commit
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.sha }}
|
ref: ${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -440,7 +496,7 @@ jobs:
|
|||||||
node scripts/generate-beta-body.cjs \
|
node scripts/generate-beta-body.cjs \
|
||||||
--version "${{ needs.prep.outputs.version }}" \
|
--version "${{ needs.prep.outputs.version }}" \
|
||||||
--branch "${{ needs.prep.outputs.dev_branch }}" \
|
--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
|
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
|
||||||
|
|
||||||
- name: Create beta release
|
- name: Create beta release
|
||||||
@@ -456,11 +512,19 @@ jobs:
|
|||||||
else
|
else
|
||||||
gh release create "$TAG" --repo ${{ github.repository }} \
|
gh release create "$TAG" --repo ${{ github.repository }} \
|
||||||
--title "$TITLE" --notes-file BETA_BODY.md \
|
--title "$TITLE" --notes-file BETA_BODY.md \
|
||||||
--prerelease --target "${{ needs.prep.outputs.sha }}"
|
--prerelease --target "${{ needs.translate.outputs.sha || needs.prep.outputs.sha }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
electron-release:
|
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' }}
|
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
|
uses: ./.github/workflows/electron.yml
|
||||||
with:
|
with:
|
||||||
@@ -468,7 +532,7 @@ jobs:
|
|||||||
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||||
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
|
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
|
secrets: inherit
|
||||||
|
|
||||||
electron-submit:
|
electron-submit:
|
||||||
@@ -541,8 +605,48 @@ jobs:
|
|||||||
|
|
||||||
# The docs site reads the env catalog, API spec and download list from the
|
# The docs site reads the env catalog, API spec and download list from the
|
||||||
# newest stable release.
|
# 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]
|
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' }}
|
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
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,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 26.10.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ cask "termix" do
|
|||||||
strategy :github_latest
|
strategy :github_latest
|
||||||
end
|
end
|
||||||
|
|
||||||
|
auto_updates true
|
||||||
|
|
||||||
app "Termix.app"
|
app "Termix.app"
|
||||||
|
|
||||||
zap trash: [
|
zap trash: [
|
||||||
|
|||||||
@@ -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. |
|
| [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. |
|
| [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 />
|
<br />
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
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
|
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||||
FROM node:24-slim AS opkssh-downloader
|
FROM node:24-slim AS opkssh-downloader
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
{
|
{
|
||||||
"id": "acme-ssl",
|
"id": "acme-ssl",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
|
||||||
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
|
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ai",
|
"id": "ai",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
|
||||||
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
|
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "alerts",
|
"id": "alerts",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
|
||||||
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
|
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -25,14 +25,14 @@
|
|||||||
{
|
{
|
||||||
"id": "automations",
|
"id": "automations",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
|
||||||
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
|
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
|
||||||
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
|
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
{
|
{
|
||||||
"id": "file-manager",
|
"id": "file-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
|
||||||
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
|
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -51,20 +51,20 @@
|
|||||||
{
|
{
|
||||||
"id": "fleets",
|
"id": "fleets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
|
||||||
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
|
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "homepage",
|
"id": "homepage",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
|
||||||
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
|
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "host-metrics",
|
"id": "host-metrics",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
|
||||||
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
|
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -73,32 +73,32 @@
|
|||||||
{
|
{
|
||||||
"id": "ldap",
|
"id": "ldap",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
|
||||||
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
|
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "network-topology",
|
"id": "network-topology",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
|
||||||
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
|
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "opkssh",
|
"id": "opkssh",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
|
||||||
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
|
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "proxmox",
|
"id": "proxmox",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
|
||||||
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
|
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remote-desktop",
|
"id": "remote-desktop",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
|
||||||
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
|
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -107,14 +107,14 @@
|
|||||||
{
|
{
|
||||||
"id": "secret-sources",
|
"id": "secret-sources",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
|
||||||
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
|
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "serial",
|
"id": "serial",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
|
||||||
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
|
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": "desktop",
|
"recommended": "desktop",
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -123,20 +123,20 @@
|
|||||||
{
|
{
|
||||||
"id": "session-recording",
|
"id": "session-recording",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
|
||||||
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
|
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "session-sharing",
|
"id": "session-sharing",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
|
||||||
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
|
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "snippets",
|
"id": "snippets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
|
||||||
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
|
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -145,8 +145,8 @@
|
|||||||
{
|
{
|
||||||
"id": "ssh-terminal",
|
"id": "ssh-terminal",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
|
||||||
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
|
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -155,8 +155,8 @@
|
|||||||
{
|
{
|
||||||
"id": "sso",
|
"id": "sso",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.1/sso-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
|
||||||
"sha256": "ba7485ba058c784975609f6960bbaf7ec2018bcdce40b47308efc92ca96fdab5",
|
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": false,
|
"recommended": false,
|
||||||
"consent": false,
|
"consent": false,
|
||||||
@@ -166,20 +166,20 @@
|
|||||||
{
|
{
|
||||||
"id": "step-ca",
|
"id": "step-ca",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
|
||||||
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
|
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tailscale",
|
"id": "tailscale",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
|
||||||
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
|
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "telemetry",
|
"id": "telemetry",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
|
||||||
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
|
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": true
|
"consent": true
|
||||||
@@ -188,20 +188,20 @@
|
|||||||
{
|
{
|
||||||
"id": "termix-identity",
|
"id": "termix-identity",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
|
||||||
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
|
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tmux-monitor",
|
"id": "tmux-monitor",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
|
||||||
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
|
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "totp",
|
"id": "totp",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
|
||||||
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
|
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -210,8 +210,8 @@
|
|||||||
{
|
{
|
||||||
"id": "tunnels",
|
"id": "tunnels",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
|
||||||
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
|
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -220,32 +220,32 @@
|
|||||||
{
|
{
|
||||||
"id": "vault",
|
"id": "vault",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
|
||||||
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
|
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wake-on-lan",
|
"id": "wake-on-lan",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
|
||||||
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
|
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "warpgate",
|
"id": "warpgate",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
|
||||||
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
|
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "web-endpoint",
|
"id": "web-endpoint",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
|
||||||
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
|
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "webauthn",
|
"id": "webauthn",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
|
||||||
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
|
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -254,8 +254,8 @@
|
|||||||
{
|
{
|
||||||
"id": "workspaces",
|
"id": "workspaces",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
|
||||||
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
|
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
|
|||||||
@@ -425,6 +425,16 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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-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/ {
|
location /host/ {
|
||||||
|
|||||||
@@ -394,6 +394,16 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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-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/ {
|
location /host/ {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||||
@@ -498,6 +498,13 @@
|
|||||||
"when": 1791550497926,
|
"when": 1791550497926,
|
||||||
"tag": "0070_drop_donation_modal",
|
"tag": "0070_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791755801066,
|
||||||
|
"tag": "0071_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
|
||||||
@@ -498,6 +498,13 @@
|
|||||||
"when": 1791550496661,
|
"when": 1791550496661,
|
||||||
"tag": "0070_drop_donation_modal",
|
"tag": "0070_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791755800419,
|
||||||
|
"tag": "0071_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||||
@@ -463,6 +463,13 @@
|
|||||||
"when": 1791550495357,
|
"when": 1791550495357,
|
||||||
"tag": "0065_drop_donation_modal",
|
"tag": "0065_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 66,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791755799726,
|
||||||
|
"tag": "0066_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"appId": "com.karmaa.termix",
|
"appId": "com.karmaa.termix",
|
||||||
"productName": "Termix",
|
"productName": "Termix",
|
||||||
"publish": null,
|
"publish": {
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "https://github.com/Termix-SSH/Termix/releases/latest/download"
|
||||||
|
},
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
@@ -114,8 +117,13 @@
|
|||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"arch": ["universal", "x64", "arm64"]
|
"arch": ["universal", "x64", "arm64"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": ["x64", "arm64"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"artifactName": "termix_macos_${arch}_update.${ext}",
|
||||||
"icon": "public/icon.icns",
|
"icon": "public/icon.icns",
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
|
|||||||
@@ -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
|
* stderr. Always returns a failure -- see the note above on why every exit
|
||||||
* that reaches this point counts as one.
|
* 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 = "") {
|
function classifyBackendFailure(stderr = "") {
|
||||||
|
if (stderr.includes(OLD_DATABASE_MARKER)) {
|
||||||
|
return { reason: "old-database", port: null };
|
||||||
|
}
|
||||||
if (stderr.includes("EADDRINUSE")) {
|
if (stderr.includes("EADDRINUSE")) {
|
||||||
return { reason: "port-in-use", port: parseConflictingPort(stderr) };
|
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() {
|
function getDesktopSettings() {
|
||||||
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
|
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const pty = require("node-pty");
|
|||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
const linkedServer = require("./linked-server.cjs");
|
const linkedServer = require("./linked-server.cjs");
|
||||||
const desktopSettings = require("./desktop-settings.cjs");
|
const desktopSettings = require("./desktop-settings.cjs");
|
||||||
|
const autoUpdates = require("./auto-updater.cjs");
|
||||||
const { launchNativeRdp } = require("./native-rdp.cjs");
|
const { launchNativeRdp } = require("./native-rdp.cjs");
|
||||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||||
const { quitApp } = require("./app-quit.cjs");
|
const { quitApp } = require("./app-quit.cjs");
|
||||||
@@ -1752,6 +1753,65 @@ ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
|||||||
return desktopSettings.saveDesktopSettings(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
|
// 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.
|
// linking so upgraded installs do not have to look it up.
|
||||||
ipcMain.handle("get-previous-server-url", () => {
|
ipcMain.handle("get-previous-server-url", () => {
|
||||||
@@ -1924,6 +1984,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
|||||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 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
|
// C2S (client-to-server) tunnels relay through the server this desktop is
|
||||||
// linked to, not the embedded backend.
|
// linked to, not the embedded backend.
|
||||||
function getC2SRemoteBaseUrl() {
|
function getC2SRemoteBaseUrl() {
|
||||||
@@ -2192,6 +2262,7 @@ async function openC2SRelay(
|
|||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
|
keepC2SSocketAlive(ws);
|
||||||
const pendingChunks = [];
|
const pendingChunks = [];
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
@@ -2299,6 +2370,7 @@ async function testC2SRelay(
|
|||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
|
keepC2SSocketAlive(ws);
|
||||||
|
|
||||||
if (runtime) runtime.authSocket = ws;
|
if (runtime) runtime.authSocket = ws;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -2609,6 +2681,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
|||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
|
keepC2SSocketAlive(ws);
|
||||||
const sockets = new Map();
|
const sockets = new Map();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
@@ -3804,6 +3877,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
createTray();
|
createTray();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
startAppUpdater();
|
||||||
desktopSettings.removeOldSyncFiles();
|
desktopSettings.removeOldSyncFiles();
|
||||||
logToFile("=== Startup complete ===");
|
logToFile("=== Startup complete ===");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
getEmbeddedServerStatus: () =>
|
getEmbeddedServerStatus: () =>
|
||||||
ipcRenderer.invoke("get-embedded-server-status"),
|
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),
|
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
isDev: process.env.NODE_ENV === "development",
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|||||||
@@ -361,10 +361,15 @@
|
|||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
"description": "Use another plugin index instead of the official registry."
|
"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",
|
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||||
"group": "plugins",
|
"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",
|
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||||
@@ -442,6 +447,18 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Read on Linux to pick the password store and window behavior."
|
"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",
|
"name": "DESKTOP_SESSION",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.0",
|
"version": "26.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.0",
|
"version": "26.10.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
@@ -106,6 +107,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.4.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"js-yaml": "^4.3.2",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"lint-staged": "^17.2.0",
|
"lint-staged": "^17.2.0",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
@@ -7589,7 +7591,6 @@
|
|||||||
"version": "9.7.0",
|
"version": "9.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
||||||
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@@ -9016,6 +9017,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/electron/node_modules/@types/node": {
|
||||||
"version": "24.19.0",
|
"version": "24.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
|
||||||
@@ -9870,7 +9899,6 @@
|
|||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -10803,7 +10831,6 @@
|
|||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
@@ -10869,7 +10896,6 @@
|
|||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
@@ -11218,6 +11244,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -11230,6 +11262,13 @@
|
|||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
@@ -13020,7 +13059,6 @@
|
|||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=11.0.0"
|
"node": ">=11.0.0"
|
||||||
@@ -13780,6 +13818,12 @@
|
|||||||
"semver": "bin/semver"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -14081,7 +14125,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
@@ -15015,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.0",
|
"version": "26.10.1",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"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",
|
"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",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"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 .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "npm run build:sdk && tsc -b --force",
|
"type-check": "npm run build:sdk && tsc -b --force",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
@@ -141,6 +142,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.4.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"js-yaml": "^4.3.2",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"lint-staged": "^17.2.0",
|
"lint-staged": "^17.2.0",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 1.0.5
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -0,0 +1,780 @@
|
|||||||
|
# Termix plugin guide
|
||||||
|
|
||||||
|
SDK 1.0.6 | 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 and rate limit them.
|
||||||
|
- `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 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
"tsconfig.plugin.json",
|
"tsconfig.plugin.json",
|
||||||
"README.md",
|
"README.md",
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
|
"PLUGIN-GUIDE.md",
|
||||||
"client.d.ts"
|
"client.d.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 |
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { createRequire } from "node:module";
|
||||||
import { describe, expect, it } from "vitest";
|
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 require = createRequire(import.meta.url);
|
||||||
const { classifyBackendFailure } =
|
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", () => {
|
it("reports a crash for any other non-zero exit", () => {
|
||||||
expect(classifyBackendFailure("TypeError: boom")).toEqual({
|
expect(classifyBackendFailure("TypeError: boom")).toEqual({
|
||||||
reason: "crashed",
|
reason: "crashed",
|
||||||
|
|||||||
@@ -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",
|
"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) {
|
function keysIn(text) {
|
||||||
return [
|
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-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
||||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
||||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
||||||
updates: keysIn(
|
updates: [
|
||||||
fs.readFileSync(
|
...new Set(
|
||||||
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
|
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
||||||
"utf8",
|
keysIn(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(ROOT, "src", "ui", "settings", file),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
plugin_id TEXT NOT NULL,
|
plugin_id TEXT NOT NULL,
|
||||||
registry_id TEXT NOT NULL,
|
registry_id TEXT NOT NULL,
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (plugin_id, registry_id)
|
UNIQUE (plugin_id, registry_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
|||||||
"donation_modal_dismissed",
|
"donation_modal_dismissed",
|
||||||
],
|
],
|
||||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||||
|
// 26.10.1 shows downloads only.
|
||||||
|
plugin_install_counts: ["source"],
|
||||||
user_preferences: [
|
user_preferences: [
|
||||||
"command_autocomplete",
|
"command_autocomplete",
|
||||||
"terminal_defaults",
|
"terminal_defaults",
|
||||||
|
|||||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = mysqlTable(
|
export const pluginInstallCounts = mysqlTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = mysqlTable(
|
|||||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||||
count: int("count").notNull().default(0),
|
count: int("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
|||||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = pgTable(
|
export const pluginInstallCounts = pgTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = pgTable(
|
|||||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||||
count: integer("count").notNull().default(0),
|
count: integer("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -1051,10 +1051,9 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = sqliteTable(
|
export const pluginInstallCounts = sqliteTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1063,7 +1062,6 @@ export const pluginInstallCounts = sqliteTable(
|
|||||||
pluginId: text("plugin_id").notNull(),
|
pluginId: text("plugin_id").notNull(),
|
||||||
registryId: text("registry_id").notNull(),
|
registryId: text("registry_id").notNull(),
|
||||||
count: integer("count").notNull().default(0),
|
count: integer("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
|||||||
export interface PluginInstallCountInput {
|
export interface PluginInstallCountInput {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
count: number;
|
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 {
|
export class PluginInstallCountRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: DatabaseContext,
|
private readonly context: DatabaseContext,
|
||||||
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
|
|||||||
let written = 0;
|
let written = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const row = existing.get(entry.pluginId);
|
const row = existing.get(entry.pluginId);
|
||||||
if (row && row.count === entry.count && row.source === entry.source) {
|
if (row && row.count === entry.count) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (row) {
|
if (row) {
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(pluginInstallCounts)
|
.update(pluginInstallCounts)
|
||||||
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
.set({ count: entry.count, updatedAt: now })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||||
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
|
|||||||
pluginId: entry.pluginId,
|
pluginId: entry.pluginId,
|
||||||
registryId,
|
registryId,
|
||||||
count: entry.count,
|
count: entry.count,
|
||||||
source: entry.source,
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
|||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line || line.startsWith("#")) continue;
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
|
||||||
const spaceIdx = line.indexOf(" ");
|
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||||
if (spaceIdx === -1) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
const key = line.slice(0, spaceIdx).toLowerCase();
|
const key = match[1].toLowerCase();
|
||||||
const value = line.slice(spaceIdx + 1).trim();
|
const value = match[2];
|
||||||
|
|
||||||
if (key === "host") {
|
if (key === "host") {
|
||||||
if (current && current.hostname) results.push(current);
|
if (current && current.hostname) results.push(current);
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
|||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.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 { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||||
|
import { RegistryError } from "../../plugins/registries.js";
|
||||||
import {
|
import {
|
||||||
pluginDocsUrl,
|
pluginDocsUrl,
|
||||||
pluginEnvVars,
|
pluginEnvVars,
|
||||||
@@ -236,6 +241,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||||
* `dependencies` and `optionalDependencies` let the loader activate
|
* `dependencies` and `optionalDependencies` let the loader activate
|
||||||
* frontends in the same order the server does.
|
* 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.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* responses:
|
* responses:
|
||||||
@@ -253,6 +261,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
userId,
|
userId,
|
||||||
"admin.plugins.manage",
|
"admin.plugins.manage",
|
||||||
);
|
);
|
||||||
|
const linked = await isLinkedDesktop();
|
||||||
|
|
||||||
// One query for every plugin rather than one per plugin.
|
// One query for every plugin rather than one per plugin.
|
||||||
const grantsByPlugin = new Map<string, string[]>();
|
const grantsByPlugin = new Map<string, string[]>();
|
||||||
@@ -285,6 +294,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
|
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -299,7 +309,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
env?: unknown;
|
env?: unknown;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
|
desktop?: unknown;
|
||||||
};
|
};
|
||||||
|
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||||
contributes = manifest?.contributes ?? null;
|
contributes = manifest?.contributes ?? null;
|
||||||
capabilities = Array.isArray(manifest?.capabilities)
|
capabilities = Array.isArray(manifest?.capabilities)
|
||||||
? (manifest.capabilities as string[])
|
? (manifest.capabilities as string[])
|
||||||
@@ -332,6 +344,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
version: record.version,
|
version: record.version,
|
||||||
enabled: record.state === "enabled",
|
enabled: record.state === "enabled",
|
||||||
state: loaded?.state ?? record.state,
|
state: loaded?.state ?? record.state,
|
||||||
|
runsOnServer: linked && desktopMode === "server",
|
||||||
contributes,
|
contributes,
|
||||||
icon,
|
icon,
|
||||||
dependencies,
|
dependencies,
|
||||||
@@ -392,6 +405,7 @@ async function auditPluginAction(
|
|||||||
action: string,
|
action: string,
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
details: Record<string, unknown> = {},
|
details: Record<string, unknown> = {},
|
||||||
|
resourceType = "plugin",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = (req as AuthenticatedRequest).userId as string;
|
const userId = (req as AuthenticatedRequest).userId as string;
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
@@ -400,7 +414,7 @@ async function auditPluginAction(
|
|||||||
userId,
|
userId,
|
||||||
username: await getAuditUsername(userId),
|
username: await getAuditUsername(userId),
|
||||||
action,
|
action,
|
||||||
resourceType: "plugin",
|
resourceType,
|
||||||
resourceId: pluginId,
|
resourceId: pluginId,
|
||||||
resourceName: pluginId,
|
resourceName: pluginId,
|
||||||
details: JSON.stringify(details),
|
details: JSON.stringify(details),
|
||||||
@@ -419,7 +433,7 @@ function sendManageError(
|
|||||||
fallback: string,
|
fallback: string,
|
||||||
operation: string,
|
operation: string,
|
||||||
): void {
|
): void {
|
||||||
if (error instanceof PluginManageError) {
|
if (error instanceof PluginManageError || error instanceof RegistryError) {
|
||||||
res.status(error.status).json({
|
res.status(error.status).json({
|
||||||
error: error.message,
|
error: error.message,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
@@ -570,6 +584,304 @@ 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
|
* @openapi
|
||||||
* /plugins/{id}/state:
|
* /plugins/{id}/state:
|
||||||
@@ -1597,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null {
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /plugins/registry:
|
* /plugins/registry:
|
||||||
* get:
|
* get:
|
||||||
* summary: List the plugins in the official registry
|
* summary: List the plugins in every registry that is on
|
||||||
* description: >
|
* description: >
|
||||||
* Fetches the official registry index (cached for 15 minutes unless
|
* Fetches the index of every registry that is on (each cached for 15
|
||||||
* refresh is set) and merges it with what is installed here: the
|
* 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
|
* installed version, the newest release this build can run, whether an
|
||||||
* update asks for new capabilities, and the pin and auto-update choices.
|
* update asks for new capabilities, and the pin and auto-update choices.
|
||||||
* tags:
|
* tags:
|
||||||
@@ -1741,10 +2054,10 @@ router.post(
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /plugins/{id}/install:
|
* /plugins/{id}/install:
|
||||||
* post:
|
* post:
|
||||||
* summary: Install a plugin from the official registry
|
* summary: Install a plugin from a registry
|
||||||
* description: >
|
* description: >
|
||||||
* Downloads the release, checks its sha256 and signature against a key
|
* Downloads the release, checks its sha256 and signature against the keys of
|
||||||
* this build trusts, loads it, grants the capabilities it declares and
|
* 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
|
* starts it along with any installed dependency that is off. Without a
|
||||||
* version the newest compatible release is used; naming an older one
|
* version the newest compatible release is used; naming an older one
|
||||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||||
@@ -1774,6 +2087,9 @@ router.post(
|
|||||||
* type: string
|
* type: string
|
||||||
* enum: [stable, beta]
|
* enum: [stable, beta]
|
||||||
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
|
* 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:
|
* capabilities:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
@@ -1786,9 +2102,9 @@ router.post(
|
|||||||
* 403:
|
* 403:
|
||||||
* description: The caller lacks admin.plugins.manage.
|
* description: The caller lacks admin.plugins.manage.
|
||||||
* 404:
|
* 404:
|
||||||
* description: Not in the official registry.
|
* description: Not in that registry.
|
||||||
* 409:
|
* 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:
|
* 502:
|
||||||
* description: The registry could not be reached.
|
* description: The registry could not be reached.
|
||||||
*/
|
*/
|
||||||
@@ -1804,15 +2120,19 @@ router.post(
|
|||||||
req.body?.channel === undefined
|
req.body?.channel === undefined
|
||||||
? "stable"
|
? "stable"
|
||||||
: parsePluginChannel(req.body.channel);
|
: parsePluginChannel(req.body.channel);
|
||||||
|
const registryId =
|
||||||
|
req.body?.registryId === undefined ? undefined : req.body.registryId;
|
||||||
if (
|
if (
|
||||||
!ID_PATTERN.test(pluginId) ||
|
!ID_PATTERN.test(pluginId) ||
|
||||||
version === null ||
|
version === null ||
|
||||||
capabilities === null ||
|
capabilities === null ||
|
||||||
channel === null
|
channel === null ||
|
||||||
|
(registryId !== undefined &&
|
||||||
|
(typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
|
||||||
) {
|
) {
|
||||||
res
|
res.status(400).json({
|
||||||
.status(400)
|
error: "Invalid plugin id, version, channel, registry or capabilities",
|
||||||
.json({ error: "Invalid plugin id, version, channel or capabilities" });
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await refuseOnLinkedDesktop(res)) return;
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
@@ -1822,11 +2142,13 @@ router.post(
|
|||||||
const result = await installPlugin(pluginId, {
|
const result = await installPlugin(pluginId, {
|
||||||
version,
|
version,
|
||||||
channel,
|
channel,
|
||||||
|
registryId,
|
||||||
userId,
|
userId,
|
||||||
capabilities,
|
capabilities,
|
||||||
});
|
});
|
||||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||||
version: result.version,
|
version: result.version,
|
||||||
|
registryId: registryId ?? "official",
|
||||||
capabilities,
|
capabilities,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -1847,7 +2169,7 @@ router.post(
|
|||||||
* post:
|
* post:
|
||||||
* summary: Update or change the installed version of a plugin
|
* summary: Update or change the installed version of a plugin
|
||||||
* description: >
|
* 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
|
* If the release declares capabilities the installed one does not, the
|
||||||
* request must set acceptCapabilities and list exactly those added
|
* request must set acceptCapabilities and list exactly those added
|
||||||
* capabilities in capabilities, otherwise it answers 409 with code
|
* capabilities in capabilities, otherwise it answers 409 with code
|
||||||
|
|||||||
@@ -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
|
* 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
|
* new capability is never applied here; admins get an alert and review it in
|
||||||
* the Plugins tab. Nothing is ever installed from here.
|
* 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;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
export async function runPluginAutoUpdate(): Promise<void> {
|
export async function runPluginAutoUpdate(): Promise<void> {
|
||||||
const { syncInstallCounts } = await import("./install-counts.js");
|
const { syncDownloadCounts } = await import("./download-counts.js");
|
||||||
await syncInstallCounts();
|
await syncDownloadCounts();
|
||||||
|
|
||||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||||
if (await isLinkedDesktop()) return;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Popularity numbers for the plugin store, copied from the registry's
|
|
||||||
* stats.json into plugin_install_counts.
|
|
||||||
*
|
|
||||||
* Two sources, and the store prefers the first:
|
|
||||||
* - "aggregate-telemetry": instances that reported the plugin enabled in the
|
|
||||||
* last 7 days through the Usage Statistics plugin. It undercounts by every
|
|
||||||
* instance that turned the report off or never installed that plugin.
|
|
||||||
* - "github-releases": downloads of the plugin's .tmxplug release assets.
|
|
||||||
* This counts downloads, not active installs: an instance that updates
|
|
||||||
* three times counts three times, and Docker images that bundle the plugin
|
|
||||||
* count once per build, not once per install.
|
|
||||||
*
|
|
||||||
* Treat both as a rough ranking, never as a user count.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
|
||||||
import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
|
|
||||||
|
|
||||||
export const TELEMETRY_SOURCE = "aggregate-telemetry";
|
|
||||||
export const DOWNLOADS_SOURCE = "github-releases";
|
|
||||||
|
|
||||||
export interface InstallCount {
|
|
||||||
count: number;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function repository() {
|
|
||||||
const { createCurrentPluginInstallCountRepository } =
|
|
||||||
await import("../database/repositories/factory.js");
|
|
||||||
return createCurrentPluginInstallCountRepository();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Picks the number to show: active installs when known, downloads otherwise. */
|
|
||||||
export function preferredCount(entry: {
|
|
||||||
downloads: number;
|
|
||||||
activeInstalls: number | null;
|
|
||||||
}): InstallCount {
|
|
||||||
return entry.activeInstalls !== null && entry.activeInstalls > 0
|
|
||||||
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
|
|
||||||
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
|
||||||
export async function syncInstallCounts(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const stats = await fetchRegistryStats();
|
|
||||||
if (!stats) return 0;
|
|
||||||
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
|
|
||||||
pluginId,
|
|
||||||
...preferredCount(entry),
|
|
||||||
}));
|
|
||||||
return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
|
|
||||||
} catch (error) {
|
|
||||||
pluginLogger.warn(
|
|
||||||
`Could not sync plugin install counts: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
{ operation: "plugin_registry" },
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readInstallCounts(
|
|
||||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
|
||||||
): Promise<Map<string, InstallCount>> {
|
|
||||||
try {
|
|
||||||
const rows = await (await repository()).listByRegistry(registryId);
|
|
||||||
return new Map(
|
|
||||||
rows.map((row) => [
|
|
||||||
row.pluginId,
|
|
||||||
{ count: row.count, source: row.source },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,11 @@ import {
|
|||||||
getPluginsDir,
|
getPluginsDir,
|
||||||
getUnpackedPluginsDir,
|
getUnpackedPluginsDir,
|
||||||
} from "./paths.js";
|
} from "./paths.js";
|
||||||
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
|
import {
|
||||||
|
requireSignedPlugins,
|
||||||
|
verifyAnyTrusted,
|
||||||
|
type KeyTier,
|
||||||
|
} from "./trust.js";
|
||||||
import { moveWithRetry } from "./fs-retry.js";
|
import { moveWithRetry } from "./fs-retry.js";
|
||||||
import {
|
import {
|
||||||
createPluginContext,
|
createPluginContext,
|
||||||
@@ -40,6 +44,7 @@ import {
|
|||||||
import { resolveRequirements } from "./service-registry.js";
|
import { resolveRequirements } from "./service-registry.js";
|
||||||
import { resolveSecretRequirements } from "./secret-registry.js";
|
import { resolveSecretRequirements } from "./secret-registry.js";
|
||||||
import { recordConflict } from "./conflicts.js";
|
import { recordConflict } from "./conflicts.js";
|
||||||
|
import { installHostModuleFallback } from "./host-modules.js";
|
||||||
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
||||||
|
|
||||||
export type PluginState =
|
export type PluginState =
|
||||||
@@ -76,6 +81,8 @@ export interface LoadedPlugin {
|
|||||||
artifact?: string;
|
artifact?: string;
|
||||||
/** Key id that signed the artifact, when a trusted key signed it. */
|
/** Key id that signed the artifact, when a trusted key signed it. */
|
||||||
signedBy?: string;
|
signedBy?: string;
|
||||||
|
/** Which set of keys that key belongs to. */
|
||||||
|
signedTier?: KeyTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginLoaderOptions {
|
export interface PluginLoaderOptions {
|
||||||
@@ -344,10 +351,11 @@ export class PluginLoader {
|
|||||||
const buffer = await fs.promises.readFile(file);
|
const buffer = await fs.promises.readFile(file);
|
||||||
const sigFile = `${file}.sig`;
|
const sigFile = `${file}.sig`;
|
||||||
let signedBy: string | undefined;
|
let signedBy: string | undefined;
|
||||||
|
let signedTier: KeyTier | undefined;
|
||||||
|
|
||||||
if (fs.existsSync(sigFile)) {
|
if (fs.existsSync(sigFile)) {
|
||||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
const result = verifyPluginArtifact(
|
const result = verifyAnyTrusted(
|
||||||
buffer,
|
buffer,
|
||||||
sha256,
|
sha256,
|
||||||
await fs.promises.readFile(sigFile, "utf8"),
|
await fs.promises.readFile(sigFile, "utf8"),
|
||||||
@@ -356,6 +364,7 @@ export class PluginLoader {
|
|||||||
throw new Error(`signature check failed: ${result.reason}`);
|
throw new Error(`signature check failed: ${result.reason}`);
|
||||||
}
|
}
|
||||||
signedBy = result.keyId;
|
signedBy = result.keyId;
|
||||||
|
signedTier = result.tier;
|
||||||
} else if (requireSignedPlugins()) {
|
} else if (requireSignedPlugins()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||||
@@ -401,7 +410,7 @@ export class PluginLoader {
|
|||||||
assertBundledUpdate(
|
assertBundledUpdate(
|
||||||
id,
|
id,
|
||||||
raw?.version,
|
raw?.version,
|
||||||
signedBy,
|
signedTier === "official" ? signedBy : undefined,
|
||||||
this.bundledVersions.get(id) ?? replaced?.manifest.version,
|
this.bundledVersions.get(id) ?? replaced?.manifest.version,
|
||||||
this.pinned.get(id),
|
this.pinned.get(id),
|
||||||
);
|
);
|
||||||
@@ -426,6 +435,7 @@ export class PluginLoader {
|
|||||||
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
|
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
|
||||||
plugin.artifact = file;
|
plugin.artifact = file;
|
||||||
plugin.signedBy = signedBy;
|
plugin.signedBy = signedBy;
|
||||||
|
plugin.signedTier = signedTier;
|
||||||
if (replaced) {
|
if (replaced) {
|
||||||
pluginLogger.info(
|
pluginLogger.info(
|
||||||
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
|
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
|
||||||
@@ -567,6 +577,7 @@ export class PluginLoader {
|
|||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
installHostModuleFallback();
|
||||||
const imported = (await import(entry)) as {
|
const imported = (await import(entry)) as {
|
||||||
activate?: PluginModule["activate"];
|
activate?: PluginModule["activate"];
|
||||||
deactivate?: PluginModule["deactivate"];
|
deactivate?: PluginModule["deactivate"];
|
||||||
@@ -615,7 +626,8 @@ export class PluginLoader {
|
|||||||
// A release signed by a trusted key is official whether it shipped in
|
// A release signed by a trusted key is official whether it shipped in
|
||||||
// the image or was downloaded, so it may adopt its 2.8 tables too.
|
// the image or was downloaded, so it may adopt its 2.8 tables too.
|
||||||
const applied = await migratePlugin(plugin.id, plugin.dir, {
|
const applied = await migratePlugin(plugin.id, plugin.dir, {
|
||||||
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
|
bundled:
|
||||||
|
plugin.source === "bundled" || plugin.signedTier === "official",
|
||||||
});
|
});
|
||||||
// A new table may be where core data is waiting to move.
|
// A new table may be where core data is waiting to move.
|
||||||
if (applied.length > 0) {
|
if (applied.length > 0) {
|
||||||
@@ -847,7 +859,7 @@ export function assertBundledUpdate(
|
|||||||
): void {
|
): void {
|
||||||
if (!signedBy) {
|
if (!signedBy) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
|
`a bundled plugin already uses the id "${id}"; only an official signed update can replace it`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof version !== "string" || !semver.valid(version)) {
|
if (typeof version !== "string" || !semver.valid(version)) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Installing, updating, uninstalling and switching plugins at runtime.
|
* Installing, updating, uninstalling and switching plugins at runtime.
|
||||||
*
|
*
|
||||||
* The official registry is the one real source. With developer mode on, an
|
* Plugins come from the official registry, plus the community and custom
|
||||||
* admin can also install a .tmxplug from a file; that skips the download,
|
* registries an admin turned on (see registries.ts). With developer mode on,
|
||||||
|
* an admin can also install a .tmxplug from a file; that skips the download,
|
||||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||||
* image and can still be uninstalled: their files are deleted and the id is
|
* image and can still be uninstalled: their files are deleted and the id is
|
||||||
* recorded in the settings table, so the shipped copy is ignored from then
|
* recorded in the settings table, so the shipped copy is ignored from then
|
||||||
@@ -38,14 +39,27 @@ import {
|
|||||||
getRegistryStatus,
|
getRegistryStatus,
|
||||||
isApiCompatible,
|
isApiCompatible,
|
||||||
latestOnChannel,
|
latestOnChannel,
|
||||||
|
officialSource,
|
||||||
parsePluginChannel,
|
parsePluginChannel,
|
||||||
type PluginChannel,
|
type PluginChannel,
|
||||||
type RegistryIndex,
|
type RegistryIndex,
|
||||||
|
type RegistryPlugin,
|
||||||
|
type RegistrySource,
|
||||||
|
type RegistryTier,
|
||||||
type RegistryVersion,
|
type RegistryVersion,
|
||||||
} from "./registry-index.js";
|
} from "./registry-index.js";
|
||||||
|
import {
|
||||||
|
findSource,
|
||||||
|
listSources,
|
||||||
|
loadCustomRegistryKeys,
|
||||||
|
pinnedKeyOf,
|
||||||
|
} from "./registries.js";
|
||||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||||
import { moveWithRetry } from "./fs-retry.js";
|
import { moveWithRetry } from "./fs-retry.js";
|
||||||
import { readInstallCounts } from "./install-counts.js";
|
import {
|
||||||
|
readDownloadCounts,
|
||||||
|
refreshDownloadCounts,
|
||||||
|
} from "./download-counts.js";
|
||||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import {
|
import {
|
||||||
parseManifest,
|
parseManifest,
|
||||||
@@ -127,6 +141,7 @@ async function readPins(): Promise<Map<string, string>> {
|
|||||||
export async function applyStoredPluginChoices(
|
export async function applyStoredPluginChoices(
|
||||||
loader: PluginLoader,
|
loader: PluginLoader,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await loadCustomRegistryKeys();
|
||||||
try {
|
try {
|
||||||
loader.setUninstalled(await readUninstalled());
|
loader.setUninstalled(await readUninstalled());
|
||||||
loader.setPinned(await readPins());
|
loader.setPinned(await readPins());
|
||||||
@@ -208,12 +223,28 @@ function addedCapabilities(
|
|||||||
return next.filter((capability) => !have.has(capability));
|
return next.filter((capability) => !have.has(capability));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegistryListingSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: RegistryTier;
|
||||||
|
url: string;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
plugins: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryListing {
|
export interface RegistryListing {
|
||||||
|
/** The official registry, kept for older clients. */
|
||||||
registry: ReturnType<typeof getRegistryStatus> & { id: string };
|
registry: ReturnType<typeof getRegistryStatus> & { id: string };
|
||||||
|
/** Every registry read for this listing, official first. */
|
||||||
|
registries: RegistryListingSource[];
|
||||||
/** A linked desktop runs what its server runs, so nothing here can change. */
|
/** A linked desktop runs what its server runs, so nothing here can change. */
|
||||||
managedByServer: boolean;
|
managedByServer: boolean;
|
||||||
plugins: Array<{
|
plugins: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
registryId: string;
|
||||||
|
registryName: string;
|
||||||
|
tier: RegistryTier;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -245,28 +276,118 @@ export interface RegistryListing {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
/** See install-counts.ts for what this number does and does not mean. */
|
/** See download-counts.ts for what this number does and does not mean. */
|
||||||
installCount: number | null;
|
downloads: number | null;
|
||||||
installCountSource: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A custom registry whose index now names another key than the pinned one. */
|
||||||
|
async function keyChanged(
|
||||||
|
source: RegistrySource,
|
||||||
|
index: RegistryIndex,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (source.tier !== "custom" || !index.publicKey) return false;
|
||||||
|
return index.publicKey !== (await pinnedKeyOf(source.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_CHANGED_MESSAGE =
|
||||||
|
"the registry now names a different signing key; add it again to trust the new key";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids only the official registry may offer: what ships in the image and what
|
||||||
|
* the official index lists. Other registries can never take them over.
|
||||||
|
*/
|
||||||
|
function reservedIds(official: RegistryIndex | null): Set<string> {
|
||||||
|
const ids = new Set(getPluginRuntime().loader.bundledIds());
|
||||||
|
for (const plugin of official?.plugins ?? []) ids.add(plugin.id);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRegistry(
|
export async function listRegistry(
|
||||||
options: { force?: boolean } = {},
|
options: { force?: boolean } = {},
|
||||||
): Promise<RegistryListing> {
|
): Promise<RegistryListing> {
|
||||||
const index = await fetchRegistryIndex(options);
|
const sources = await listSources();
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
sources.map((source) => fetchRegistryIndex(source, options)),
|
||||||
|
);
|
||||||
|
const official = results[0];
|
||||||
|
if (official.status === "rejected") throw official.reason;
|
||||||
|
|
||||||
const { createCurrentPluginRepository } = await repos();
|
const { createCurrentPluginRepository } = await repos();
|
||||||
const records = new Map(
|
const records = new Map(
|
||||||
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
||||||
);
|
);
|
||||||
const counts = await readInstallCounts();
|
await refreshDownloadCounts(options.force);
|
||||||
|
const counts = new Map(
|
||||||
|
await Promise.all(
|
||||||
|
sources
|
||||||
|
.filter((source) => source.tier !== "custom")
|
||||||
|
.map(
|
||||||
|
async (source) =>
|
||||||
|
[source.id, await readDownloadCounts(source.id)] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
const shipped = loader.bundledIds();
|
const shipped = loader.bundledIds();
|
||||||
|
const reserved = reservedIds(official.value);
|
||||||
|
|
||||||
|
const registries: RegistryListingSource[] = [];
|
||||||
|
// Per id, every registry that lists it, in source order.
|
||||||
|
const offers = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ source: RegistrySource; plugin: RegistryPlugin }>
|
||||||
|
>();
|
||||||
|
for (let i = 0; i < sources.length; i++) {
|
||||||
|
const source = sources[i];
|
||||||
|
const result = results[i];
|
||||||
|
const status = getRegistryStatus(source);
|
||||||
|
let error = status.error;
|
||||||
|
let listed = 0;
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
if (await keyChanged(source, result.value)) {
|
||||||
|
error = KEY_CHANGED_MESSAGE;
|
||||||
|
} else {
|
||||||
|
for (const plugin of result.value.plugins) {
|
||||||
|
if (source.tier !== "official" && reserved.has(plugin.id)) continue;
|
||||||
|
const list = offers.get(plugin.id) ?? [];
|
||||||
|
list.push({ source, plugin });
|
||||||
|
offers.set(plugin.id, list);
|
||||||
|
listed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error =
|
||||||
|
result.reason instanceof Error
|
||||||
|
? result.reason.message
|
||||||
|
: String(result.reason);
|
||||||
|
}
|
||||||
|
registries.push({
|
||||||
|
id: source.id,
|
||||||
|
name:
|
||||||
|
result.status === "fulfilled" && result.value.name
|
||||||
|
? result.value.name
|
||||||
|
: source.name,
|
||||||
|
tier: source.tier,
|
||||||
|
url: source.url,
|
||||||
|
lastCheckedAt: status.lastCheckedAt,
|
||||||
|
error,
|
||||||
|
plugins: listed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen: Array<{ source: RegistrySource; plugin: RegistryPlugin }> = [];
|
||||||
|
for (const [id, list] of offers) {
|
||||||
|
// An installed plugin is always shown from the registry it came from.
|
||||||
|
const from = records.get(id)?.registryId;
|
||||||
|
chosen.push(list.find((offer) => offer.source.id === from) ?? list[0]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() },
|
registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() },
|
||||||
|
registries,
|
||||||
managedByServer: await isLinkedDesktop(),
|
managedByServer: await isLinkedDesktop(),
|
||||||
plugins: index.plugins.map((plugin) => {
|
plugins: chosen.map(({ source, plugin }) => {
|
||||||
const record = records.get(plugin.id);
|
const record = records.get(plugin.id);
|
||||||
const live = loader.get(plugin.id);
|
const live = loader.get(plugin.id);
|
||||||
const channel = channelOf(record);
|
const channel = channelOf(record);
|
||||||
@@ -283,6 +404,9 @@ export async function listRegistry(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
|
registryId: source.id,
|
||||||
|
registryName: registries.find((entry) => entry.id === source.id)!.name,
|
||||||
|
tier: source.tier,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
description: plugin.description,
|
description: plugin.description,
|
||||||
author: plugin.author,
|
author: plugin.author,
|
||||||
@@ -318,8 +442,7 @@ export async function listRegistry(
|
|||||||
pinnedVersion: record?.pinnedVersion ?? null,
|
pinnedVersion: record?.pinnedVersion ?? null,
|
||||||
autoUpdate: Boolean(record?.autoUpdate),
|
autoUpdate: Boolean(record?.autoUpdate),
|
||||||
bundled: shipped.has(plugin.id),
|
bundled: shipped.has(plugin.id),
|
||||||
installCount: counts.get(plugin.id)?.count ?? null,
|
downloads: counts.get(source.id)?.get(plugin.id) ?? null,
|
||||||
installCountSource: counts.get(plugin.id)?.source ?? null,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -362,7 +485,8 @@ async function grantDeclared(
|
|||||||
invalidatePluginPermissionCache(plugin.id);
|
invalidatePluginPermissionCache(plugin.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwapSource = { release: RegistryVersion } | { upload: string };
|
type SwapSource =
|
||||||
|
{ release: RegistryVersion; source: RegistrySource } | { upload: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts a registry release, or an uploaded file, in place of whatever copy of
|
* Puts a registry release, or an uploaded file, in place of whatever copy of
|
||||||
@@ -377,6 +501,7 @@ async function swapIn(
|
|||||||
const bundledVersion = loader.bundledVersion(pluginId);
|
const bundledVersion = loader.bundledVersion(pluginId);
|
||||||
const useBundled =
|
const useBundled =
|
||||||
"release" in source &&
|
"release" in source &&
|
||||||
|
source.source.tier === "official" &&
|
||||||
bundledVersion !== undefined &&
|
bundledVersion !== undefined &&
|
||||||
bundledVersion === source.release.version &&
|
bundledVersion === source.release.version &&
|
||||||
isBundledOnDisk(pluginId);
|
isBundledOnDisk(pluginId);
|
||||||
@@ -385,7 +510,7 @@ async function swapIn(
|
|||||||
const staged = useBundled
|
const staged = useBundled
|
||||||
? null
|
? null
|
||||||
: "release" in source
|
: "release" in source
|
||||||
? await downloadRelease(pluginId, source.release)
|
? await downloadRelease(pluginId, source.release, source.source.keys)
|
||||||
: { file: source.upload, signatureFile: `${source.upload}.sig` };
|
: { file: source.upload, signatureFile: `${source.upload}.sig` };
|
||||||
|
|
||||||
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||||
@@ -654,6 +779,8 @@ export interface InstallOptions {
|
|||||||
version?: string;
|
version?: string;
|
||||||
/** Install only: the channel to follow from the start. */
|
/** Install only: the channel to follow from the start. */
|
||||||
channel?: PluginChannel;
|
channel?: PluginChannel;
|
||||||
|
/** Install only: the registry to install from. Official when unset. */
|
||||||
|
registryId?: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
acceptCapabilities?: boolean;
|
acceptCapabilities?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -684,33 +811,68 @@ function assertConsent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unreachable(error: unknown): PluginManageError {
|
||||||
|
return new PluginManageError(
|
||||||
|
`Could not reach the plugin registry: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
502,
|
||||||
|
"REGISTRY_UNREACHABLE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function requireRelease(
|
async function requireRelease(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
version: string | undefined,
|
version: string | undefined,
|
||||||
channel: PluginChannel,
|
channel: PluginChannel,
|
||||||
|
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
index: RegistryIndex;
|
source: RegistrySource;
|
||||||
release: RegistryVersion;
|
release: RegistryVersion;
|
||||||
latest: RegistryVersion | null;
|
latest: RegistryVersion | null;
|
||||||
}> {
|
}> {
|
||||||
|
const source = await findSource(registryId);
|
||||||
|
if (!source) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`The registry "${registryId}" is turned off or was removed`,
|
||||||
|
409,
|
||||||
|
"REGISTRY_OFF",
|
||||||
|
);
|
||||||
|
}
|
||||||
let index: RegistryIndex;
|
let index: RegistryIndex;
|
||||||
try {
|
try {
|
||||||
index = await fetchRegistryIndex();
|
index = await fetchRegistryIndex(source);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
throw unreachable(error);
|
||||||
|
}
|
||||||
|
if (await keyChanged(source, index)) {
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
`Could not reach the plugin registry: ${
|
`${source.name}: ${KEY_CHANGED_MESSAGE}`,
|
||||||
error instanceof Error ? error.message : String(error)
|
409,
|
||||||
}`,
|
"KEY_CHANGED",
|
||||||
502,
|
|
||||||
"REGISTRY_UNREACHABLE",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (source.tier !== "official") {
|
||||||
|
let official: RegistryIndex;
|
||||||
|
try {
|
||||||
|
official = await fetchRegistryIndex(officialSource());
|
||||||
|
} catch (error) {
|
||||||
|
throw unreachable(error);
|
||||||
|
}
|
||||||
|
if (reservedIds(official).has(pluginId)) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`${pluginId} is an official plugin and can only come from the official registry`,
|
||||||
|
409,
|
||||||
|
"RESERVED_ID",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const found = findRelease(index, pluginId, version, channel);
|
const found = findRelease(index, pluginId, version, channel);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
version
|
version
|
||||||
? `${pluginId} ${version} is not in the official registry`
|
? `${pluginId} ${version} is not in ${source.name}`
|
||||||
: `${pluginId} is not in the official registry`,
|
: `${pluginId} is not in ${source.name}`,
|
||||||
404,
|
404,
|
||||||
"NOT_IN_REGISTRY",
|
"NOT_IN_REGISTRY",
|
||||||
);
|
);
|
||||||
@@ -723,12 +885,20 @@ async function requireRelease(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
index,
|
source,
|
||||||
release: found.release,
|
release: found.release,
|
||||||
latest: latestOnChannel(found.plugin, channel),
|
latest: latestOnChannel(found.plugin, channel),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a registry install is recorded as. */
|
||||||
|
function tierFor(plugin: LoadedPlugin, source: RegistrySource): string {
|
||||||
|
if (source.tier === "official" && plugin.source === "bundled") {
|
||||||
|
return "bundled";
|
||||||
|
}
|
||||||
|
return source.tier;
|
||||||
|
}
|
||||||
|
|
||||||
/** The version to hold at: an explicit pick that is not the newest. */
|
/** The version to hold at: an explicit pick that is not the newest. */
|
||||||
function pinFor(
|
function pinFor(
|
||||||
release: RegistryVersion,
|
release: RegistryVersion,
|
||||||
@@ -763,10 +933,11 @@ export function installPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channel = options.channel ?? "stable";
|
const channel = options.channel ?? "stable";
|
||||||
const { release, latest } = await requireRelease(
|
const { source, release, latest } = await requireRelease(
|
||||||
pluginId,
|
pluginId,
|
||||||
options.version,
|
options.version,
|
||||||
channel,
|
channel,
|
||||||
|
options.registryId,
|
||||||
);
|
);
|
||||||
assertConsent(options.capabilities, release.capabilities);
|
assertConsent(options.capabilities, release.capabilities);
|
||||||
const pin = pinFor(release, latest, options.version);
|
const pin = pinFor(release, latest, options.version);
|
||||||
@@ -774,7 +945,7 @@ export function installPlugin(
|
|||||||
|
|
||||||
let plugin: LoadedPlugin;
|
let plugin: LoadedPlugin;
|
||||||
try {
|
try {
|
||||||
plugin = await swapIn(pluginId, { release });
|
plugin = await swapIn(pluginId, { release, source });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await applyPin(pluginId, null);
|
await applyPin(pluginId, null);
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
@@ -785,8 +956,8 @@ export function installPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repository.update(pluginId, {
|
await repository.update(pluginId, {
|
||||||
registryId: OFFICIAL_REGISTRY_ID,
|
registryId: source.id,
|
||||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
tier: tierFor(plugin, source),
|
||||||
pinnedVersion: pin,
|
pinnedVersion: pin,
|
||||||
channel,
|
channel,
|
||||||
});
|
});
|
||||||
@@ -832,10 +1003,11 @@ async function updateUnlocked(
|
|||||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { release, latest } = await requireRelease(
|
const { source, release, latest } = await requireRelease(
|
||||||
pluginId,
|
pluginId,
|
||||||
options.version,
|
options.version,
|
||||||
channelOf(record),
|
channelOf(record),
|
||||||
|
record.registryId ?? OFFICIAL_REGISTRY_ID,
|
||||||
);
|
);
|
||||||
if (release.version === live.manifest.version) {
|
if (release.version === live.manifest.version) {
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
@@ -867,7 +1039,7 @@ async function updateUnlocked(
|
|||||||
|
|
||||||
let plugin: LoadedPlugin;
|
let plugin: LoadedPlugin;
|
||||||
try {
|
try {
|
||||||
plugin = await swapIn(pluginId, { release });
|
plugin = await swapIn(pluginId, { release, source });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await applyPin(pluginId, record.pinnedVersion ?? null);
|
await applyPin(pluginId, record.pinnedVersion ?? null);
|
||||||
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
||||||
@@ -879,8 +1051,8 @@ async function updateUnlocked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repository.update(pluginId, {
|
await repository.update(pluginId, {
|
||||||
registryId: OFFICIAL_REGISTRY_ID,
|
registryId: source.id,
|
||||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
tier: tierFor(plugin, source),
|
||||||
pinnedVersion: pin,
|
pinnedVersion: pin,
|
||||||
});
|
});
|
||||||
await grantDeclared(plugin, options.userId);
|
await grantDeclared(plugin, options.userId);
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Which registries this server reads plugins from.
|
||||||
|
*
|
||||||
|
* Official is always on. Community is off until an admin turns it on. Custom
|
||||||
|
* registries are added by URL: their index names the key they sign with, the
|
||||||
|
* admin confirms its fingerprint, and that key is pinned. A later key change
|
||||||
|
* in the index is never followed, the admin has to add the registry again.
|
||||||
|
*
|
||||||
|
* Stored as one JSON value in the settings table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
OFFICIAL_REGISTRY_ID,
|
||||||
|
COMMUNITY_REGISTRY_ID,
|
||||||
|
communitySource,
|
||||||
|
fetchIndexAt,
|
||||||
|
officialSource,
|
||||||
|
type RegistrySource,
|
||||||
|
} from "./registry-index.js";
|
||||||
|
import {
|
||||||
|
isPluginPublicKey,
|
||||||
|
pluginKeyId,
|
||||||
|
setCustomPluginKeys,
|
||||||
|
type TrustedPluginKey,
|
||||||
|
} from "./trust.js";
|
||||||
|
|
||||||
|
const CONFIG_KEY = "plugin_registries";
|
||||||
|
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||||
|
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||||
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
const MAX_ID_LENGTH = 40;
|
||||||
|
const MAX_NAME_LENGTH = 80;
|
||||||
|
const MAX_CUSTOM = 20;
|
||||||
|
|
||||||
|
export interface CustomRegistry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
publicKey: string;
|
||||||
|
keyId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistriesConfig {
|
||||||
|
community: { enabled: boolean };
|
||||||
|
custom: CustomRegistry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegistryError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
readonly details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settings() {
|
||||||
|
const { createCurrentSettingsRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
return createCurrentSettingsRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustom(raw: unknown): CustomRegistry | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const entry = raw as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof entry.id !== "string" ||
|
||||||
|
!ID_PATTERN.test(entry.id) ||
|
||||||
|
typeof entry.url !== "string" ||
|
||||||
|
!isPluginPublicKey(entry.publicKey)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: typeof entry.name === "string" ? entry.name : entry.id,
|
||||||
|
url: entry.url,
|
||||||
|
publicKey: entry.publicKey,
|
||||||
|
keyId: pluginKeyId(entry.publicKey),
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
addedAt: typeof entry.addedAt === "string" ? entry.addedAt : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRegistriesConfig(raw: string | null): RegistriesConfig {
|
||||||
|
const config: RegistriesConfig = {
|
||||||
|
community: { enabled: false },
|
||||||
|
custom: [],
|
||||||
|
};
|
||||||
|
if (!raw) return config;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const community = parsed?.community as Record<string, unknown> | undefined;
|
||||||
|
config.community.enabled = community?.enabled === true;
|
||||||
|
if (Array.isArray(parsed?.custom)) {
|
||||||
|
config.custom = parsed.custom
|
||||||
|
.map(parseCustom)
|
||||||
|
.filter((entry): entry is CustomRegistry => entry !== null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A broken value falls back to official only.
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRegistriesConfig(): Promise<RegistriesConfig> {
|
||||||
|
return parseRegistriesConfig(await (await settings()).get(CONFIG_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
||||||
|
await (await settings()).set(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
await applyCustomKeys(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKey(publicKey: string): TrustedPluginKey {
|
||||||
|
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSyncedKeys(): Promise<string[]> {
|
||||||
|
const raw = await (await settings()).get(SYNCED_KEYS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(isPluginPublicKey) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||||
|
* from a registry that was later turned off still has to load.
|
||||||
|
*/
|
||||||
|
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||||
|
const keys = new Map<string, TrustedPluginKey>();
|
||||||
|
for (const entry of config.custom) {
|
||||||
|
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||||
|
}
|
||||||
|
for (const publicKey of await readSyncedKeys()) {
|
||||||
|
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
||||||
|
}
|
||||||
|
setCustomPluginKeys([...keys.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called before the boot scan so plugins from custom registries load. */
|
||||||
|
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await applyCustomKeys(await readRegistriesConfig());
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Could not read plugin registries: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ operation: "plugin_init" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A linked desktop trusts the keys of its server's custom registries. */
|
||||||
|
export async function setSyncedRegistryKeys(
|
||||||
|
publicKeys: readonly unknown[],
|
||||||
|
): Promise<void> {
|
||||||
|
const valid = [...new Set(publicKeys.filter(isPluginPublicKey))].sort();
|
||||||
|
const current = (await readSyncedKeys()).sort();
|
||||||
|
if (valid.join() !== current.join()) {
|
||||||
|
await (await settings()).set(SYNCED_KEYS_KEY, JSON.stringify(valid));
|
||||||
|
}
|
||||||
|
await applyCustomKeys(await readRegistriesConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customSource(entry: CustomRegistry): RegistrySource {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
url: entry.url,
|
||||||
|
tier: "custom",
|
||||||
|
keys: [toKey(entry.publicKey)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every registry plugins can come from right now, official first. */
|
||||||
|
export async function listSources(): Promise<RegistrySource[]> {
|
||||||
|
const config = await readRegistriesConfig();
|
||||||
|
const sources = [officialSource()];
|
||||||
|
if (config.community.enabled) sources.push(communitySource());
|
||||||
|
for (const entry of config.custom) {
|
||||||
|
if (entry.enabled) sources.push(customSource(entry));
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSource(id: string): Promise<RegistrySource | null> {
|
||||||
|
return (await listSources()).find((source) => source.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pinned key of a custom registry, for spotting a changed index key. */
|
||||||
|
export async function pinnedKeyOf(id: string): Promise<string | null> {
|
||||||
|
const config = await readRegistriesConfig();
|
||||||
|
return config.custom.find((entry) => entry.id === id)?.publicKey ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistryPreview {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
keyId: string;
|
||||||
|
plugins: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(input: unknown): string {
|
||||||
|
if (typeof input !== "string" || !input.trim()) {
|
||||||
|
throw new RegistryError("Enter the index URL", 400, "INVALID_URL");
|
||||||
|
}
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(input.trim());
|
||||||
|
} catch {
|
||||||
|
throw new RegistryError("That is not a valid URL", 400, "INVALID_URL");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw new RegistryError("The index must use https", 400, "INVALID_URL");
|
||||||
|
}
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseId(raw: string): string {
|
||||||
|
const cleaned = raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, MAX_ID_LENGTH);
|
||||||
|
return ID_PATTERN.test(cleaned) ? cleaned : "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueId(raw: string, config: RegistriesConfig): string {
|
||||||
|
const taken = new Set<string>([
|
||||||
|
OFFICIAL_REGISTRY_ID,
|
||||||
|
COMMUNITY_REGISTRY_ID,
|
||||||
|
...config.custom.map((entry) => entry.id),
|
||||||
|
]);
|
||||||
|
const base = baseId(raw);
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const candidate = `${base}-${n}`;
|
||||||
|
if (!taken.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspect(
|
||||||
|
input: unknown,
|
||||||
|
config: RegistriesConfig,
|
||||||
|
): Promise<RegistryPreview & { publicKey: string }> {
|
||||||
|
const url = normalizeUrl(input);
|
||||||
|
if (
|
||||||
|
url === officialSource().url ||
|
||||||
|
url === communitySource().url ||
|
||||||
|
config.custom.some((entry) => entry.url === url)
|
||||||
|
) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"This registry is already added",
|
||||||
|
409,
|
||||||
|
"ALREADY_ADDED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let index;
|
||||||
|
try {
|
||||||
|
index = await fetchIndexAt(url);
|
||||||
|
} catch (error) {
|
||||||
|
throw new RegistryError(
|
||||||
|
`Could not read the registry: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
502,
|
||||||
|
"REGISTRY_UNREACHABLE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!index.publicKey) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"The index has no publicKey, so its plugins cannot be checked",
|
||||||
|
400,
|
||||||
|
"NO_PUBLIC_KEY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: uniqueId(index.registry || "custom", config),
|
||||||
|
name: (index.name || index.registry || url).slice(0, MAX_NAME_LENGTH),
|
||||||
|
url,
|
||||||
|
keyId: pluginKeyId(index.publicKey),
|
||||||
|
publicKey: index.publicKey,
|
||||||
|
plugins: index.plugins.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads an index so the admin can check its name and key before adding. */
|
||||||
|
export async function previewRegistry(url: unknown): Promise<RegistryPreview> {
|
||||||
|
const { publicKey: _publicKey, ...preview } = await inspect(
|
||||||
|
url,
|
||||||
|
await readRegistriesConfig(),
|
||||||
|
);
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a custom registry. `keyId` is the fingerprint the admin saw in the
|
||||||
|
* preview; the index is read again and must still name that key.
|
||||||
|
*/
|
||||||
|
export async function addRegistry(
|
||||||
|
url: unknown,
|
||||||
|
keyId: unknown,
|
||||||
|
): Promise<CustomRegistry> {
|
||||||
|
const config = await readRegistriesConfig();
|
||||||
|
if (config.custom.length >= MAX_CUSTOM) {
|
||||||
|
throw new RegistryError(
|
||||||
|
`At most ${MAX_CUSTOM} custom registries can be added`,
|
||||||
|
400,
|
||||||
|
"TOO_MANY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const preview = await inspect(url, config);
|
||||||
|
if (typeof keyId !== "string" || keyId !== preview.keyId) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"The registry key changed since the preview. Check it again",
|
||||||
|
409,
|
||||||
|
"KEY_MISMATCH",
|
||||||
|
{ keyId: preview.keyId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entry: CustomRegistry = {
|
||||||
|
id: preview.id,
|
||||||
|
name: preview.name,
|
||||||
|
url: preview.url,
|
||||||
|
publicKey: preview.publicKey,
|
||||||
|
keyId: preview.keyId,
|
||||||
|
enabled: true,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
config.custom.push(entry);
|
||||||
|
await writeRegistriesConfig(config);
|
||||||
|
pluginLogger.info(`Added plugin registry ${entry.id} (${entry.url})`, {
|
||||||
|
operation: "plugin_registry",
|
||||||
|
});
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRegistryEnabled(
|
||||||
|
id: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const config = await readRegistriesConfig();
|
||||||
|
if (id === OFFICIAL_REGISTRY_ID) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"The official registry is always on",
|
||||||
|
400,
|
||||||
|
"OFFICIAL",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id === COMMUNITY_REGISTRY_ID) {
|
||||||
|
config.community.enabled = enabled;
|
||||||
|
} else {
|
||||||
|
const entry = config.custom.find((candidate) => candidate.id === id);
|
||||||
|
if (!entry) {
|
||||||
|
throw new RegistryError("Registry not found", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
entry.enabled = enabled;
|
||||||
|
}
|
||||||
|
await writeRegistriesConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a custom registry. Refused while plugins from it are installed:
|
||||||
|
* its key has to stay trusted for them to load.
|
||||||
|
*/
|
||||||
|
export async function removeRegistry(id: string): Promise<void> {
|
||||||
|
if (id === OFFICIAL_REGISTRY_ID || id === COMMUNITY_REGISTRY_ID) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"Built in registries can only be turned off",
|
||||||
|
400,
|
||||||
|
"BUILT_IN",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = await readRegistriesConfig();
|
||||||
|
if (!config.custom.some((candidate) => candidate.id === id)) {
|
||||||
|
throw new RegistryError("Registry not found", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const { createCurrentPluginRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const using = (await createCurrentPluginRepository().listAll())
|
||||||
|
.filter((record) => record.registryId === id)
|
||||||
|
.map((record) => record.id);
|
||||||
|
if (using.length > 0) {
|
||||||
|
throw new RegistryError(
|
||||||
|
"Uninstall the plugins from this registry first",
|
||||||
|
409,
|
||||||
|
"REGISTRY_IN_USE",
|
||||||
|
{ plugins: using },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
config.custom = config.custom.filter((candidate) => candidate.id !== id);
|
||||||
|
await writeRegistriesConfig(config);
|
||||||
|
pluginLogger.info(`Removed plugin registry ${id}`, {
|
||||||
|
operation: "plugin_registry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public keys of the custom registries, for a linked desktop. */
|
||||||
|
export async function customRegistryKeys(): Promise<string[]> {
|
||||||
|
return (await readRegistriesConfig()).custom.map((entry) => entry.publicKey);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* The official plugin registry: fetching its index and downloading a release.
|
* Plugin registries: fetching an index and downloading a release.
|
||||||
*
|
*
|
||||||
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
|
* An index is a plain JSON file. Official and community live in
|
||||||
* is trusted on its own: every download is checked against the sha256 and
|
* Termix-SSH/Termix-Registry, custom ones anywhere an admin points to.
|
||||||
* Ed25519 signature listed for it, and the signature must come from a key
|
* Nothing in an index is trusted on its own: every download is checked
|
||||||
* pinned in trust.ts. A tampered index can at most point at a file that then
|
* against the sha256 and Ed25519 signature listed for it, and the signature
|
||||||
* fails the check.
|
* must come from that registry's keys (built in for official and community,
|
||||||
|
* pinned when an admin added a custom one). A tampered index can at most
|
||||||
|
* point at a file that then fails the check.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
@@ -20,13 +22,33 @@ import {
|
|||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
import { verifyPluginArtifact } from "./trust.js";
|
import {
|
||||||
|
COMMUNITY_PLUGIN_KEYS,
|
||||||
|
TRUSTED_PLUGIN_KEYS,
|
||||||
|
isPluginPublicKey,
|
||||||
|
verifyPluginArtifact,
|
||||||
|
type TrustedPluginKey,
|
||||||
|
} from "./trust.js";
|
||||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import { getPluginsDir } from "./paths.js";
|
import { getPluginsDir } from "./paths.js";
|
||||||
|
|
||||||
export const OFFICIAL_REGISTRY_ID = "official";
|
export const OFFICIAL_REGISTRY_ID = "official";
|
||||||
export const OFFICIAL_REGISTRY_URL =
|
export const OFFICIAL_REGISTRY_URL =
|
||||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
|
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
|
||||||
|
export const COMMUNITY_REGISTRY_ID = "community";
|
||||||
|
export const COMMUNITY_REGISTRY_URL =
|
||||||
|
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json";
|
||||||
|
|
||||||
|
export type RegistryTier = "official" | "community" | "custom";
|
||||||
|
|
||||||
|
/** Where an index lives and which keys may sign what it lists. */
|
||||||
|
export interface RegistrySource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
tier: RegistryTier;
|
||||||
|
keys: readonly TrustedPluginKey[];
|
||||||
|
}
|
||||||
|
|
||||||
const CACHE_MS = 15 * 60_000;
|
const CACHE_MS = 15 * 60_000;
|
||||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||||
@@ -83,6 +105,8 @@ export interface RegistryIndex {
|
|||||||
registry: string;
|
registry: string;
|
||||||
name: string;
|
name: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
/** The key the registry signs with, base64. Only read when adding one. */
|
||||||
|
publicKey?: string;
|
||||||
plugins: RegistryPlugin[];
|
plugins: RegistryPlugin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +114,26 @@ export function getRegistryUrl(): string {
|
|||||||
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
|
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function officialSource(): RegistrySource {
|
||||||
|
return {
|
||||||
|
id: OFFICIAL_REGISTRY_ID,
|
||||||
|
name: "Termix Official Plugins",
|
||||||
|
url: getRegistryUrl(),
|
||||||
|
tier: "official",
|
||||||
|
keys: TRUSTED_PLUGIN_KEYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communitySource(): RegistrySource {
|
||||||
|
return {
|
||||||
|
id: COMMUNITY_REGISTRY_ID,
|
||||||
|
name: "Termix Community Plugins",
|
||||||
|
url: process.env.TERMIX_COMMUNITY_REGISTRY_URL || COMMUNITY_REGISTRY_URL,
|
||||||
|
tier: "community",
|
||||||
|
keys: COMMUNITY_PLUGIN_KEYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
function asString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
}
|
}
|
||||||
@@ -174,6 +218,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
|||||||
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
|
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
|
||||||
name: asString(index.name) ?? "",
|
name: asString(index.name) ?? "",
|
||||||
updatedAt: asString(index.updatedAt),
|
updatedAt: asString(index.updatedAt),
|
||||||
|
publicKey: isPluginPublicKey(index.publicKey) ? index.publicKey : undefined,
|
||||||
plugins,
|
plugins,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -228,8 +273,18 @@ async function readLimited(response: Response, limit: number): Promise<Buffer> {
|
|||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
|
interface CacheEntry {
|
||||||
let inflight: Promise<RegistryIndex> | null = null;
|
index: RegistryIndex;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caches = new Map<string, CacheEntry>();
|
||||||
|
const inflight = new Map<string, Promise<RegistryIndex>>();
|
||||||
|
const lastErrors = new Map<string, string>();
|
||||||
|
|
||||||
|
function cacheKey(source: RegistrySource): string {
|
||||||
|
return `${source.id} ${source.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryStatus {
|
export interface RegistryStatus {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -237,63 +292,83 @@ export interface RegistryStatus {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastError: string | null = null;
|
export function getRegistryStatus(
|
||||||
|
source: RegistrySource = officialSource(),
|
||||||
export function getRegistryStatus(): RegistryStatus {
|
): RegistryStatus {
|
||||||
|
const cache = caches.get(cacheKey(source));
|
||||||
return {
|
return {
|
||||||
url: getRegistryUrl(),
|
url: source.url,
|
||||||
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
|
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
|
||||||
error: lastError,
|
error: lastErrors.get(cacheKey(source)) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches and parses an index with no caching, for previewing a new one. */
|
||||||
|
export async function fetchIndexAt(url: string): Promise<RegistryIndex> {
|
||||||
|
const response = await fetchFollowing(url);
|
||||||
|
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(body.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new Error("the registry index is not valid JSON");
|
||||||
|
}
|
||||||
|
return parseRegistryIndex(raw);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRegistryIndex(
|
export async function fetchRegistryIndex(
|
||||||
|
source: RegistrySource = officialSource(),
|
||||||
options: { force?: boolean } = {},
|
options: { force?: boolean } = {},
|
||||||
): Promise<RegistryIndex> {
|
): Promise<RegistryIndex> {
|
||||||
|
const key = cacheKey(source);
|
||||||
|
const cache = caches.get(key);
|
||||||
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
|
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
|
||||||
return cache.index;
|
return cache.index;
|
||||||
}
|
}
|
||||||
if (inflight) return inflight;
|
const running = inflight.get(key);
|
||||||
|
if (running) return running;
|
||||||
|
|
||||||
inflight = (async () => {
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetchFollowing(getRegistryUrl());
|
const index = await fetchIndexAt(source.url);
|
||||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
caches.set(key, { index, at: Date.now() });
|
||||||
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
|
lastErrors.delete(key);
|
||||||
cache = {
|
|
||||||
index,
|
|
||||||
at: Date.now(),
|
|
||||||
hash: crypto.createHash("sha256").update(body).digest("hex"),
|
|
||||||
};
|
|
||||||
lastError = null;
|
|
||||||
return index;
|
return index;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
lastErrors.set(key, message);
|
||||||
// A stale index beats none when the registry is briefly unreachable.
|
// A stale index beats none when the registry is briefly unreachable.
|
||||||
if (cache) {
|
const stale = caches.get(key);
|
||||||
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
|
if (stale) {
|
||||||
operation: "plugin_registry",
|
pluginLogger.warn(
|
||||||
});
|
`Plugin registry ${source.id} check failed: ${message}`,
|
||||||
return cache.index;
|
{ operation: "plugin_registry" },
|
||||||
|
);
|
||||||
|
return stale.index;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
inflight = null;
|
inflight.delete(key);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return inflight;
|
inflight.set(key, request);
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryStats {
|
export interface RegistryStats {
|
||||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
/** Downloads per plugin id. */
|
||||||
|
plugins: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
||||||
export function getStatsUrl(): string | null {
|
export function getStatsUrl(
|
||||||
if (process.env.TERMIX_PLUGIN_STATS_URL) {
|
source: RegistrySource = officialSource(),
|
||||||
|
): string | null {
|
||||||
|
if (source.tier === "custom") return null;
|
||||||
|
if (source.tier === "official" && process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||||
return process.env.TERMIX_PLUGIN_STATS_URL;
|
return process.env.TERMIX_PLUGIN_STATS_URL;
|
||||||
}
|
}
|
||||||
const url = getRegistryUrl();
|
const url = source.url;
|
||||||
return /\/index\.json$/.test(url)
|
return /\/index\.json$/.test(url)
|
||||||
? url.replace(/\/index\.json$/, "/stats.json")
|
? url.replace(/\/index\.json$/, "/stats.json")
|
||||||
: null;
|
: null;
|
||||||
@@ -306,27 +381,22 @@ function asCount(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseRegistryStats(raw: unknown): RegistryStats {
|
export function parseRegistryStats(raw: unknown): RegistryStats {
|
||||||
const plugins = new Map<
|
const plugins = new Map<string, number>();
|
||||||
string,
|
|
||||||
{ downloads: number; activeInstalls: number | null }
|
|
||||||
>();
|
|
||||||
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
||||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||||
return { plugins };
|
return { plugins };
|
||||||
}
|
}
|
||||||
for (const [id, value] of Object.entries(entries)) {
|
for (const [id, value] of Object.entries(entries)) {
|
||||||
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
||||||
const entry = value as Record<string, unknown>;
|
plugins.set(id, asCount((value as Record<string, unknown>).downloads) ?? 0);
|
||||||
plugins.set(id, {
|
|
||||||
downloads: asCount(entry.downloads) ?? 0,
|
|
||||||
activeInstalls: asCount(entry.activeInstalls),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { plugins };
|
return { plugins };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
export async function fetchRegistryStats(
|
||||||
const url = getStatsUrl();
|
source: RegistrySource = officialSource(),
|
||||||
|
): Promise<RegistryStats | null> {
|
||||||
|
const url = getStatsUrl(source);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const response = await fetchFollowing(url);
|
const response = await fetchFollowing(url);
|
||||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||||
@@ -335,9 +405,9 @@ export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
|||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
export function resetRegistryCache(): void {
|
export function resetRegistryCache(): void {
|
||||||
cache = null;
|
caches.clear();
|
||||||
inflight = null;
|
inflight.clear();
|
||||||
lastError = null;
|
lastErrors.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every release a channel may install, newest first. */
|
/** Every release a channel may install, newest first. */
|
||||||
@@ -414,6 +484,7 @@ export function assertArtifactMatches(
|
|||||||
export async function downloadRelease(
|
export async function downloadRelease(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
release: RegistryVersion,
|
release: RegistryVersion,
|
||||||
|
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||||
): Promise<{ file: string; signatureFile: string }> {
|
): Promise<{ file: string; signatureFile: string }> {
|
||||||
if (!isApiCompatible(release.api)) {
|
if (!isApiCompatible(release.api)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -433,6 +504,7 @@ export async function downloadRelease(
|
|||||||
buffer,
|
buffer,
|
||||||
release.sha256,
|
release.sha256,
|
||||||
release.signature,
|
release.signature,
|
||||||
|
keys,
|
||||||
);
|
);
|
||||||
if (verified.ok === false) {
|
if (verified.ok === false) {
|
||||||
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
||||||
|
|||||||
@@ -28,6 +28,49 @@ export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs community plugins after a person reviewed them. Its own tier on
|
||||||
|
* purpose: it can never replace a bundled plugin or count as official.
|
||||||
|
*/
|
||||||
|
export const COMMUNITY_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
||||||
|
{
|
||||||
|
id: "be5b24ea07b36624",
|
||||||
|
publicKey: "LYdHHGJeinCZ7UA8R/o5i1P5W3+dzktTDn5bxp4nPpI=",
|
||||||
|
addedIn: "26.10.1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type KeyTier = "official" | "community" | "custom";
|
||||||
|
|
||||||
|
/** Keys of registries an admin added, pinned when they were added. */
|
||||||
|
let customKeys: readonly TrustedPluginKey[] = [];
|
||||||
|
|
||||||
|
export function setCustomPluginKeys(keys: readonly TrustedPluginKey[]): void {
|
||||||
|
customKeys = keys.filter((key) => toPublicKey(key.publicKey) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomPluginKeys(): readonly TrustedPluginKey[] {
|
||||||
|
return customKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First 16 hex chars of the sha256 of a raw base64 public key. */
|
||||||
|
export function pluginKeyId(publicKey: string): string {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(Buffer.from(publicKey, "base64"))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a string is a raw base64 Ed25519 public key. */
|
||||||
|
export function isPluginPublicKey(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
/^[A-Za-z0-9+/]{43}=$/.test(value) &&
|
||||||
|
toPublicKey(value) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type ArtifactVerification =
|
export type ArtifactVerification =
|
||||||
{ ok: true; keyId: string } | { ok: false; reason: string };
|
{ ok: true; keyId: string } | { ok: false; reason: string };
|
||||||
|
|
||||||
@@ -86,6 +129,31 @@ export function verifyPluginArtifact(
|
|||||||
return { ok: false, reason: "signature does not match a trusted key" };
|
return { ok: false, reason: "signature does not match a trusted key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a file against every key this build trusts, official first, and
|
||||||
|
* says which tier signed it.
|
||||||
|
*/
|
||||||
|
export function verifyAnyTrusted(
|
||||||
|
buffer: Buffer,
|
||||||
|
sha256: string,
|
||||||
|
signature: string,
|
||||||
|
): { ok: true; keyId: string; tier: KeyTier } | { ok: false; reason: string } {
|
||||||
|
const tiers: Array<[KeyTier, readonly TrustedPluginKey[]]> = [
|
||||||
|
["official", TRUSTED_PLUGIN_KEYS],
|
||||||
|
["community", COMMUNITY_PLUGIN_KEYS],
|
||||||
|
["custom", customKeys],
|
||||||
|
];
|
||||||
|
let reason = "signature does not match a trusted key";
|
||||||
|
for (const [tier, keys] of tiers) {
|
||||||
|
if (keys.length === 0) continue;
|
||||||
|
const result = verifyPluginArtifact(buffer, sha256, signature, keys);
|
||||||
|
if (result.ok === true) return { ok: true, keyId: result.keyId, tier };
|
||||||
|
reason = result.reason;
|
||||||
|
if (reason !== "signature does not match a trusted key") break;
|
||||||
|
}
|
||||||
|
return { ok: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
||||||
export function requireSignedPlugins(): boolean {
|
export function requireSignedPlugins(): boolean {
|
||||||
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
unloadPlugin,
|
unloadPlugin,
|
||||||
} from "../../plugins/index.js";
|
} from "../../plugins/index.js";
|
||||||
import { getPluginsDir } from "../../plugins/paths.js";
|
import { getPluginsDir } from "../../plugins/paths.js";
|
||||||
|
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
||||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||||
import {
|
import {
|
||||||
createCurrentPluginPermissionGrantRepository,
|
createCurrentPluginPermissionGrantRepository,
|
||||||
@@ -283,10 +284,12 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||||
const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>(
|
const { plugins, registryKeys } = await remoteJson<{
|
||||||
link,
|
plugins: RemotePlugin[];
|
||||||
"/sync/v2/plugins",
|
registryKeys?: unknown[];
|
||||||
);
|
}>(link, "/sync/v2/plugins");
|
||||||
|
// Before any download: a plugin from a custom registry needs its key.
|
||||||
|
await setSyncedRegistryKeys(Array.isArray(registryKeys) ? registryKeys : []);
|
||||||
lastRemote = plugins;
|
lastRemote = plugins;
|
||||||
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
|
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
|
||||||
|
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ async function listPluginsForDesktop() {
|
|||||||
* /sync/v2/plugins:
|
* /sync/v2/plugins:
|
||||||
* get:
|
* get:
|
||||||
* summary: The features this server runs, for a linked desktop to mirror
|
* summary: The features this server runs, for a linked desktop to mirror
|
||||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns.
|
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
|
||||||
* tags:
|
* tags:
|
||||||
* - Sync
|
* - Sync
|
||||||
* responses:
|
* responses:
|
||||||
@@ -509,7 +509,9 @@ router.get(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({ plugins });
|
const { customRegistryKeys } =
|
||||||
|
await import("../../plugins/registries.js");
|
||||||
|
res.json({ plugins, registryKeys: await customRegistryKeys() });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncLogger.error("Failed to list plugins for sync", error, {
|
syncLogger.error("Failed to list plugins for sync", error, {
|
||||||
operation: "sync_plugins",
|
operation: "sync_plugins",
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ Host alias-only
|
|||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts tabs and = between keywords and values", () => {
|
||||||
|
const config = "Host\tweb\n\tHostName=10.0.0.5\n\tUser = deploy\n";
|
||||||
|
expect(parseSSHConfig(config)).toEqual([
|
||||||
|
{ name: "web", hostname: "10.0.0.5", user: "deploy" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults port to undefined when not specified", () => {
|
it("defaults port to undefined when not specified", () => {
|
||||||
const config = `
|
const config = `
|
||||||
Host server
|
Host server
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PluginManageError } from "../../../plugins/manage.js";
|
import { PluginManageError } from "../../../plugins/manage.js";
|
||||||
|
import { RegistryError } from "../../../plugins/registries.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
|||||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const registriesMock = vi.hoisted(() => ({
|
||||||
|
readRegistriesConfig: vi.fn(),
|
||||||
|
previewRegistry: vi.fn(),
|
||||||
|
addRegistry: vi.fn(),
|
||||||
|
setRegistryEnabled: vi.fn(),
|
||||||
|
removeRegistry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
|
||||||
|
...registriesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
const onboardingMock = vi.hoisted(() => ({
|
const onboardingMock = vi.hoisted(() => ({
|
||||||
describeOnboardingPlugins: vi.fn(),
|
describeOnboardingPlugins: vi.fn(),
|
||||||
applyOnboardingChoices: vi.fn(),
|
applyOnboardingChoices: vi.fn(),
|
||||||
@@ -443,6 +457,28 @@ describe("plugins route", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks server-only plugins on a linked desktop", async () => {
|
||||||
|
state.plugins.set(
|
||||||
|
"sample-plugin",
|
||||||
|
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const read = async () =>
|
||||||
|
(
|
||||||
|
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||||
|
runsOnServer: boolean;
|
||||||
|
}>
|
||||||
|
)[0].runsOnServer;
|
||||||
|
|
||||||
|
expect(await read()).toBe(false);
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||||
|
try {
|
||||||
|
expect(await read()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("grants a declared capability", async () => {
|
it("grants a declared capability", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin());
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
@@ -577,6 +613,7 @@ describe("plugins route", () => {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
state: "enabled",
|
state: "enabled",
|
||||||
|
runsOnServer: false,
|
||||||
contributes: null,
|
contributes: null,
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
optionalDependencies: {},
|
optionalDependencies: {},
|
||||||
@@ -744,6 +781,190 @@ describe("plugins route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("plugin registries", () => {
|
||||||
|
const call = (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
user = "admin-1",
|
||||||
|
) =>
|
||||||
|
fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const fn of Object.values(registriesMock)) fn.mockReset();
|
||||||
|
manageMock.isLinkedDesktop.mockReset();
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||||
|
auditMock.logAudit.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is admin only", async () => {
|
||||||
|
for (const [method, path, body] of [
|
||||||
|
["GET", "/plugins/registries", undefined],
|
||||||
|
[
|
||||||
|
"POST",
|
||||||
|
"/plugins/registries/preview",
|
||||||
|
{ url: "https://x.com/i.json" },
|
||||||
|
],
|
||||||
|
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
|
||||||
|
["PATCH", "/plugins/registries/community", { enabled: true }],
|
||||||
|
["DELETE", "/plugins/registries/acme", undefined],
|
||||||
|
] as const) {
|
||||||
|
const res = await call(method, path, body, "user-2");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
}
|
||||||
|
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||||
|
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the registries without the full public keys", async () => {
|
||||||
|
registriesMock.readRegistriesConfig.mockResolvedValue({
|
||||||
|
community: { enabled: true },
|
||||||
|
custom: [
|
||||||
|
{
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
publicKey: "secret-looking",
|
||||||
|
keyId: "abcd",
|
||||||
|
enabled: true,
|
||||||
|
addedAt: "2026-10-11T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await call("GET", "/plugins/registries");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.community.enabled).toBe(true);
|
||||||
|
expect(body.custom[0]).toEqual({
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
keyId: "abcd",
|
||||||
|
enabled: true,
|
||||||
|
addedAt: "2026-10-11T00:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews and adds a registry and audits the add", async () => {
|
||||||
|
registriesMock.previewRegistry.mockResolvedValue({
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
keyId: "abcd",
|
||||||
|
plugins: 2,
|
||||||
|
});
|
||||||
|
const preview = await call("POST", "/plugins/registries/preview", {
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
});
|
||||||
|
expect(preview.status).toBe(200);
|
||||||
|
expect((await preview.json()).keyId).toBe("abcd");
|
||||||
|
|
||||||
|
registriesMock.addRegistry.mockResolvedValue({
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
publicKey: "k",
|
||||||
|
keyId: "abcd",
|
||||||
|
enabled: true,
|
||||||
|
addedAt: "2026-10-11T00:00:00Z",
|
||||||
|
});
|
||||||
|
const added = await call("POST", "/plugins/registries", {
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
keyId: "abcd",
|
||||||
|
});
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
|
||||||
|
"https://acme.example/index.json",
|
||||||
|
"abcd",
|
||||||
|
);
|
||||||
|
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "add_plugin_registry",
|
||||||
|
resourceType: "plugin_registry",
|
||||||
|
resourceId: "acme",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes registry errors through with their code", async () => {
|
||||||
|
registriesMock.addRegistry.mockRejectedValue(
|
||||||
|
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
|
||||||
|
);
|
||||||
|
const res = await call("POST", "/plugins/registries", {
|
||||||
|
url: "https://acme.example/index.json",
|
||||||
|
keyId: "old",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
error: "changed",
|
||||||
|
code: "KEY_MISMATCH",
|
||||||
|
keyId: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a registry on and off and checks the body", async () => {
|
||||||
|
expect(
|
||||||
|
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
|
||||||
|
.status,
|
||||||
|
).toBe(400);
|
||||||
|
const res = await call("PATCH", "/plugins/registries/community", {
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
|
||||||
|
"community",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to remove a registry still in use", async () => {
|
||||||
|
registriesMock.removeRegistry.mockRejectedValue(
|
||||||
|
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
|
||||||
|
plugins: ["tool"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await call("DELETE", "/plugins/registries/acme");
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect((await res.json()).plugins).toEqual(["tool"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves registries to the server on a linked desktop", async () => {
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||||
|
const res = await call("PATCH", "/plugins/registries/community", {
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs from the registry the caller names", async () => {
|
||||||
|
manageMock.installPlugin.mockReset();
|
||||||
|
manageMock.installPlugin.mockResolvedValue({
|
||||||
|
id: "neat",
|
||||||
|
version: "1.0.0",
|
||||||
|
state: "active",
|
||||||
|
});
|
||||||
|
const res = await call("POST", "/plugins/neat/install", {
|
||||||
|
registryId: "community",
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(manageMock.installPlugin).toHaveBeenCalledWith(
|
||||||
|
"neat",
|
||||||
|
expect.objectContaining({ registryId: "community" }),
|
||||||
|
);
|
||||||
|
const bad = await call("POST", "/plugins/neat/install", {
|
||||||
|
registryId: "../x",
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("install, update, uninstall", () => {
|
describe("install, update, uninstall", () => {
|
||||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||||
fetch(`${baseUrl}${path}`, {
|
fetch(`${baseUrl}${path}`, {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function setup() {
|
|||||||
getC2SRelayHeaders: headers,
|
getC2SRelayHeaders: headers,
|
||||||
getWebSocketOptions: (_url: string, options: unknown) => options,
|
getWebSocketOptions: (_url: string, options: unknown) => options,
|
||||||
attachC2SAuth: vi.fn(),
|
attachC2SAuth: vi.fn(),
|
||||||
|
keepC2SSocketAlive: vi.fn(),
|
||||||
logToFile: vi.fn(),
|
logToFile: vi.fn(),
|
||||||
emitC2STunnelStatuses: vi.fn(),
|
emitC2STunnelStatuses: vi.fn(),
|
||||||
checkLocalPortAvailable: async () => ({ available: true }),
|
checkLocalPortAvailable: async () => ({ available: true }),
|
||||||
@@ -177,3 +178,32 @@ describe("client tunnel runtime authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("client tunnel socket keepalive", () => {
|
||||||
|
it("pings an open socket until it closes", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const context = vm.createContext({
|
||||||
|
setInterval,
|
||||||
|
clearInterval,
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const ws = Object.assign(new EventEmitter(), {
|
||||||
|
readyState: 1,
|
||||||
|
ping: vi.fn(),
|
||||||
|
});
|
||||||
|
context.keepC2SSocketAlive(ws);
|
||||||
|
vi.advanceTimersByTime(50_000);
|
||||||
|
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||||
|
ws.emit("close");
|
||||||
|
vi.advanceTimersByTime(50_000);
|
||||||
|
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
stats: null as null | { plugins: Map<string, number> },
|
||||||
|
fail: false,
|
||||||
|
sources: [{ id: "official", tier: "official" }] as Array<{
|
||||||
|
id: string;
|
||||||
|
tier: string;
|
||||||
|
}>,
|
||||||
|
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
|
||||||
|
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||||
|
rows: [] as Array<{ pluginId: string; count: number }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/registry-index.js", () => ({
|
||||||
|
OFFICIAL_REGISTRY_ID: "official",
|
||||||
|
officialSource: () => ({ id: "official", tier: "official" }),
|
||||||
|
fetchRegistryStats: async (source: { id: string }) => {
|
||||||
|
if (state.fail) throw new Error("offline");
|
||||||
|
return state.statsBySource[source.id] ?? state.stats;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/registries.js", () => ({
|
||||||
|
listSources: async () => state.sources,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginInstallCountRepository: () => ({
|
||||||
|
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||||
|
state.upserted.push({ registryId, entries });
|
||||||
|
return entries.length;
|
||||||
|
},
|
||||||
|
listByRegistry: async () => state.rows,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
readDownloadCounts,
|
||||||
|
refreshDownloadCounts,
|
||||||
|
resetDownloadCountRefresh,
|
||||||
|
syncDownloadCounts,
|
||||||
|
} = await import("../../plugins/download-counts.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.stats = null;
|
||||||
|
state.fail = false;
|
||||||
|
state.sources = [{ id: "official", tier: "official" }];
|
||||||
|
state.statsBySource = {};
|
||||||
|
state.upserted = [];
|
||||||
|
state.rows = [];
|
||||||
|
resetDownloadCountRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshDownloadCounts", () => {
|
||||||
|
const docker = () => ({ plugins: new Map([["docker", 5]]) });
|
||||||
|
|
||||||
|
it("syncs on the first look and not again while fresh", async () => {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync again right away even when forced", async () => {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
await refreshDownloadCounts(true);
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs again when forced after a minute", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
try {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
vi.setSystemTime(Date.now() + 61_000);
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
await refreshDownloadCounts(true);
|
||||||
|
expect(state.upserted).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncDownloadCounts", () => {
|
||||||
|
it("writes one row per plugin", async () => {
|
||||||
|
state.stats = {
|
||||||
|
plugins: new Map([
|
||||||
|
["docker", 50],
|
||||||
|
["tunnels", 8],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
expect(await syncDownloadCounts()).toBe(2);
|
||||||
|
expect(state.upserted).toEqual([
|
||||||
|
{
|
||||||
|
registryId: "official",
|
||||||
|
entries: [
|
||||||
|
{ pluginId: "docker", count: 50 },
|
||||||
|
{ pluginId: "tunnels", count: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores counts per registry and skips custom ones", async () => {
|
||||||
|
state.sources = [
|
||||||
|
{ id: "official", tier: "official" },
|
||||||
|
{ id: "community", tier: "community" },
|
||||||
|
{ id: "mine", tier: "custom" },
|
||||||
|
];
|
||||||
|
state.statsBySource = {
|
||||||
|
official: { plugins: new Map([["docker", 5]]) },
|
||||||
|
community: { plugins: new Map([["neat", 3]]) },
|
||||||
|
mine: null,
|
||||||
|
};
|
||||||
|
expect(await syncDownloadCounts()).toBe(2);
|
||||||
|
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
||||||
|
"official",
|
||||||
|
"community",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
expect(await syncDownloadCounts()).toBe(0);
|
||||||
|
expect(state.upserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for a registry with no stats file", async () => {
|
||||||
|
expect(await syncDownloadCounts()).toBe(0);
|
||||||
|
expect(state.upserted).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readDownloadCounts", () => {
|
||||||
|
it("maps the stored rows by plugin id", async () => {
|
||||||
|
state.rows = [{ pluginId: "docker", count: 20 }];
|
||||||
|
const counts = await readDownloadCounts();
|
||||||
|
expect(counts.get("docker")).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isBareSpecifier,
|
||||||
|
isUserPluginFile,
|
||||||
|
} from "../../plugins/host-modules.js";
|
||||||
|
|
||||||
|
describe("isBareSpecifier", () => {
|
||||||
|
it("accepts package names", () => {
|
||||||
|
expect(isBareSpecifier("drizzle-orm")).toBe(true);
|
||||||
|
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
|
||||||
|
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses paths, urls and builtins", () => {
|
||||||
|
expect(isBareSpecifier("./local.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("../up.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("/abs/file.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("file:///x.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||||
|
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("#internal")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isUserPluginFile", () => {
|
||||||
|
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||||
|
|
||||||
|
it("matches files under the plugins dir, query included", () => {
|
||||||
|
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
|
||||||
|
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores everything else", () => {
|
||||||
|
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
|
||||||
|
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile(undefined, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installHostModuleFallback", () => {
|
||||||
|
let dataDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
dataDir = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A real Node process: vitest resolves imports on its own and would hide
|
||||||
|
// what plain Node does.
|
||||||
|
function importFromPlugin(withFallback: boolean) {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
|
||||||
|
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const entry = path.join(dir, "backend.mjs");
|
||||||
|
fs.writeFileSync(
|
||||||
|
entry,
|
||||||
|
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
|
||||||
|
);
|
||||||
|
const hook = pathToFileURL(
|
||||||
|
path.resolve(__dirname, "../../plugins/host-modules.ts"),
|
||||||
|
).href;
|
||||||
|
const script = [
|
||||||
|
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
|
||||||
|
withFallback ? "installHostModuleFallback();" : "",
|
||||||
|
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
|
||||||
|
].join("\n");
|
||||||
|
return spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["--import", "tsx", "--input-type=module", "-e", script],
|
||||||
|
{
|
||||||
|
cwd: path.resolve(__dirname, "../../../.."),
|
||||||
|
env: { ...process.env, DATA_DIR: dataDir },
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets a plugin outside the app import core's packages", () => {
|
||||||
|
const result = importFromPlugin(true);
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(result.stdout.trim()).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the same import without it", () => {
|
||||||
|
const result = importFromPlugin(false);
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
|
||||||
stats: null as null | {
|
|
||||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
|
||||||
},
|
|
||||||
fail: false,
|
|
||||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
|
||||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../plugins/registry-index.js", () => ({
|
|
||||||
OFFICIAL_REGISTRY_ID: "official",
|
|
||||||
fetchRegistryStats: async () => {
|
|
||||||
if (state.fail) throw new Error("offline");
|
|
||||||
return state.stats;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
|
||||||
createCurrentPluginInstallCountRepository: () => ({
|
|
||||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
|
||||||
state.upserted.push({ registryId, entries });
|
|
||||||
return entries.length;
|
|
||||||
},
|
|
||||||
listByRegistry: async () => state.rows,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
|
||||||
DOWNLOADS_SOURCE,
|
|
||||||
TELEMETRY_SOURCE,
|
|
||||||
preferredCount,
|
|
||||||
readInstallCounts,
|
|
||||||
syncInstallCounts,
|
|
||||||
} = await import("../../plugins/install-counts.js");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.stats = null;
|
|
||||||
state.fail = false;
|
|
||||||
state.upserted = [];
|
|
||||||
state.rows = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("preferredCount", () => {
|
|
||||||
it("prefers active installs and falls back to downloads", () => {
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
|
||||||
count: 12,
|
|
||||||
source: TELEMETRY_SOURCE,
|
|
||||||
});
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
|
||||||
count: 90,
|
|
||||||
source: DOWNLOADS_SOURCE,
|
|
||||||
});
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
|
||||||
count: 90,
|
|
||||||
source: DOWNLOADS_SOURCE,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("syncInstallCounts", () => {
|
|
||||||
it("writes one row per plugin with its preferred source", async () => {
|
|
||||||
state.stats = {
|
|
||||||
plugins: new Map([
|
|
||||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
|
||||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
expect(await syncInstallCounts()).toBe(2);
|
|
||||||
expect(state.upserted).toEqual([
|
|
||||||
{
|
|
||||||
registryId: "official",
|
|
||||||
entries: [
|
|
||||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
|
||||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
|
||||||
state.fail = true;
|
|
||||||
expect(await syncInstallCounts()).toBe(0);
|
|
||||||
expect(state.upserted).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing for a registry with no stats file", async () => {
|
|
||||||
expect(await syncInstallCounts()).toBe(0);
|
|
||||||
expect(state.upserted).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("readInstallCounts", () => {
|
|
||||||
it("maps the stored rows by plugin id", async () => {
|
|
||||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
|
||||||
const counts = await readInstallCounts();
|
|
||||||
expect(counts.get("docker")).toEqual({
|
|
||||||
count: 20,
|
|
||||||
source: TELEMETRY_SOURCE,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
|
|||||||
|
|
||||||
const registry = vi.hoisted(() => ({
|
const registry = vi.hoisted(() => ({
|
||||||
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
||||||
|
/** Indexes of the other registries, by id. Missing means unreachable. */
|
||||||
|
others: {} as Record<string, Record<string, unknown>>,
|
||||||
download: vi.fn(),
|
download: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
|||||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
fetchRegistryIndex: async (source?: { id: string }) => {
|
||||||
|
if (!source || source.id === "official") {
|
||||||
|
return actual.parseRegistryIndex(registry.index);
|
||||||
|
}
|
||||||
|
const other = registry.others[source.id];
|
||||||
|
if (!other) throw new Error(`${source.id} is offline`);
|
||||||
|
return actual.parseRegistryIndex(other);
|
||||||
|
},
|
||||||
downloadRelease: registry.download,
|
downloadRelease: registry.download,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -216,6 +226,7 @@ beforeEach(() => {
|
|||||||
db.settings.clear();
|
db.settings.clear();
|
||||||
db.grants.length = 0;
|
db.grants.length = 0;
|
||||||
registry.index.plugins = [];
|
registry.index.plugins = [];
|
||||||
|
registry.others = {};
|
||||||
registry.download.mockReset();
|
registry.download.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -856,3 +867,220 @@ describe("release notes", () => {
|
|||||||
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("more registries", () => {
|
||||||
|
const customKey = (() => {
|
||||||
|
const { publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
return publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64");
|
||||||
|
})();
|
||||||
|
|
||||||
|
function turnOnCommunity(): void {
|
||||||
|
db.settings.set(
|
||||||
|
"plugin_registries",
|
||||||
|
JSON.stringify({ community: { enabled: true }, custom: [] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists community plugins only when it is on, never official ids", async () => {
|
||||||
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
name: "Community",
|
||||||
|
plugins: [
|
||||||
|
registryEntry("docker", [{ version: "9.0.0" }]),
|
||||||
|
registryEntry("neat", [{ version: "1.0.0" }]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
let listing = await manage.listRegistry();
|
||||||
|
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||||
|
expect(listing.registries.map((r) => r.id)).toEqual(["official"]);
|
||||||
|
|
||||||
|
turnOnCommunity();
|
||||||
|
listing = await manage.listRegistry();
|
||||||
|
expect(
|
||||||
|
listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]),
|
||||||
|
).toEqual([
|
||||||
|
["docker", "official", "official", "1.0.0"],
|
||||||
|
["neat", "community", "community", "1.0.0"],
|
||||||
|
]);
|
||||||
|
expect(listing.registries.find((r) => r.id === "community")).toMatchObject({
|
||||||
|
tier: "community",
|
||||||
|
plugins: 1,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps listing official plugins when another registry is down", async () => {
|
||||||
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||||
|
turnOnCommunity();
|
||||||
|
await boot();
|
||||||
|
const listing = await manage.listRegistry();
|
||||||
|
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||||
|
expect(listing.registries.find((r) => r.id === "community")?.error).toMatch(
|
||||||
|
/offline/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs from the community registry with its keys and records it", async () => {
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||||
|
};
|
||||||
|
turnOnCommunity();
|
||||||
|
await boot();
|
||||||
|
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||||
|
|
||||||
|
const result = await manage.installPlugin("neat", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "community",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
|
expect(result.state).toBe("active");
|
||||||
|
const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>;
|
||||||
|
expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]);
|
||||||
|
expect(db.rows.get("neat")).toMatchObject({
|
||||||
|
registryId: "community",
|
||||||
|
tier: "community",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a plugin from the registry it came from", async () => {
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
plugins: [
|
||||||
|
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
turnOnCommunity();
|
||||||
|
await boot();
|
||||||
|
registry.download.mockImplementation(() =>
|
||||||
|
stageArtifact("neat", { version: "1.0.0" }),
|
||||||
|
);
|
||||||
|
await manage.installPlugin("neat", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "community",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
|
await manage.setPluginOptions("neat", { pinned: false });
|
||||||
|
|
||||||
|
registry.download.mockReset();
|
||||||
|
registry.download.mockImplementation(() =>
|
||||||
|
stageArtifact("neat", { version: "1.1.0" }),
|
||||||
|
);
|
||||||
|
const result = await manage.updatePlugin("neat", { userId: "admin" });
|
||||||
|
expect(result.version).toBe("1.1.0");
|
||||||
|
const [, release, keys] = registry.download.mock.calls[0];
|
||||||
|
expect(release.url).toContain("neat-1.1.0");
|
||||||
|
expect(keys.map((key: { id: string }) => key.id)).toEqual([
|
||||||
|
"be5b24ea07b36624",
|
||||||
|
]);
|
||||||
|
expect(db.rows.get("neat")).toMatchObject({ registryId: "community" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops updating a community plugin once the official registry lists its id", async () => {
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
plugins: [
|
||||||
|
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
turnOnCommunity();
|
||||||
|
await boot();
|
||||||
|
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||||
|
await manage.installPlugin("neat", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "community",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
});
|
||||||
|
registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])];
|
||||||
|
await expect(
|
||||||
|
manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }),
|
||||||
|
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an official id from another registry", async () => {
|
||||||
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
plugins: [registryEntry("docker", [{ version: "9.0.0" }])],
|
||||||
|
};
|
||||||
|
turnOnCommunity();
|
||||||
|
await boot();
|
||||||
|
await expect(
|
||||||
|
manage.installPlugin("docker", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "community",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||||
|
expect(registry.download).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a registry that is off", async () => {
|
||||||
|
registry.others.community = {
|
||||||
|
registry: "community",
|
||||||
|
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||||
|
};
|
||||||
|
await boot();
|
||||||
|
await expect(
|
||||||
|
manage.installPlugin("neat", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "community",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "REGISTRY_OFF" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops trusting a custom registry whose index names a new key", async () => {
|
||||||
|
const other = crypto
|
||||||
|
.generateKeyPairSync("ed25519")
|
||||||
|
.publicKey.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64");
|
||||||
|
db.settings.set(
|
||||||
|
"plugin_registries",
|
||||||
|
JSON.stringify({
|
||||||
|
community: { enabled: false },
|
||||||
|
custom: [
|
||||||
|
{
|
||||||
|
id: "mine",
|
||||||
|
name: "Mine",
|
||||||
|
url: "https://example.com/index.json",
|
||||||
|
publicKey: customKey,
|
||||||
|
enabled: true,
|
||||||
|
addedAt: "2026-10-11T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
registry.others.mine = {
|
||||||
|
registry: "mine",
|
||||||
|
publicKey: customKey,
|
||||||
|
plugins: [registryEntry("tool", [{ version: "1.0.0" }])],
|
||||||
|
};
|
||||||
|
await boot();
|
||||||
|
let listing = await manage.listRegistry();
|
||||||
|
expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom");
|
||||||
|
|
||||||
|
registry.others.mine.publicKey = other;
|
||||||
|
listing = await manage.listRegistry();
|
||||||
|
expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined();
|
||||||
|
expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch(
|
||||||
|
/different signing key/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
manage.installPlugin("tool", {
|
||||||
|
userId: "admin",
|
||||||
|
registryId: "mine",
|
||||||
|
capabilities: DEFAULT_CAPS,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
settings: new Map<string, string>(),
|
||||||
|
plugins: [] as Array<{ id: string; registryId: string | null }>,
|
||||||
|
indexes: new Map<string, unknown>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
get: async (key: string) => state.settings.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) =>
|
||||||
|
void state.settings.set(key, value),
|
||||||
|
}),
|
||||||
|
createCurrentPluginRepository: () => ({
|
||||||
|
listAll: async () => state.plugins,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchIndexAt: async (url: string) => {
|
||||||
|
const raw = state.indexes.get(url);
|
||||||
|
if (!raw) throw new Error("answered 404");
|
||||||
|
return actual.parseRegistryIndex(raw);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const registries = await import("../../plugins/registries.js");
|
||||||
|
const { getCustomPluginKeys, pluginKeyId } =
|
||||||
|
await import("../../plugins/trust.js");
|
||||||
|
|
||||||
|
function newKey(): string {
|
||||||
|
return crypto
|
||||||
|
.generateKeyPairSync("ed25519")
|
||||||
|
.publicKey.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_A = "https://example.com/a/index.json";
|
||||||
|
|
||||||
|
function serve(url: string, index: Record<string, unknown>): void {
|
||||||
|
state.indexes.set(url, { plugins: [], ...index });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
state.settings.clear();
|
||||||
|
state.plugins = [];
|
||||||
|
state.indexes.clear();
|
||||||
|
await registries.loadCustomRegistryKeys();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registry sources", () => {
|
||||||
|
it("is official only by default", async () => {
|
||||||
|
const sources = await registries.listSources();
|
||||||
|
expect(sources.map((source) => source.id)).toEqual(["official"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds community when it is turned on", async () => {
|
||||||
|
await registries.setRegistryEnabled("community", true);
|
||||||
|
const sources = await registries.listSources();
|
||||||
|
expect(sources.map((source) => [source.id, source.tier])).toEqual([
|
||||||
|
["official", "official"],
|
||||||
|
["community", "community"],
|
||||||
|
]);
|
||||||
|
await registries.setRegistryEnabled("community", false);
|
||||||
|
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||||
|
"official",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never turns the official registry off", async () => {
|
||||||
|
await expect(
|
||||||
|
registries.setRegistryEnabled("official", false),
|
||||||
|
).rejects.toMatchObject({ code: "OFFICIAL" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a broken stored value as official only", () => {
|
||||||
|
expect(registries.parseRegistriesConfig("{nope")).toEqual({
|
||||||
|
community: { enabled: false },
|
||||||
|
custom: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adding a custom registry", () => {
|
||||||
|
it("previews the name, plugin count and key, then pins that key", async () => {
|
||||||
|
const key = newKey();
|
||||||
|
serve(URL_A, {
|
||||||
|
registry: "acme",
|
||||||
|
name: "Acme Plugins",
|
||||||
|
publicKey: key,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "tool",
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
api: "1",
|
||||||
|
url: "https://example.com/tool.tmxplug",
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
signature: "sig",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const preview = await registries.previewRegistry(URL_A);
|
||||||
|
expect(preview).toEqual({
|
||||||
|
id: "acme",
|
||||||
|
name: "Acme Plugins",
|
||||||
|
url: URL_A,
|
||||||
|
keyId: pluginKeyId(key),
|
||||||
|
plugins: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await registries.addRegistry(URL_A, preview.keyId);
|
||||||
|
expect(added).toMatchObject({ id: "acme", enabled: true, publicKey: key });
|
||||||
|
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||||
|
const sources = await registries.listSources();
|
||||||
|
expect(sources.at(-1)).toMatchObject({
|
||||||
|
id: "acme",
|
||||||
|
tier: "custom",
|
||||||
|
url: URL_A,
|
||||||
|
});
|
||||||
|
expect(sources.at(-1)?.keys.map((k) => k.publicKey)).toEqual([key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the key changed after the preview", async () => {
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||||
|
const preview = await registries.previewRegistry(URL_A);
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||||
|
await expect(
|
||||||
|
registries.addRegistry(URL_A, preview.keyId),
|
||||||
|
).rejects.toMatchObject({ code: "KEY_MISMATCH" });
|
||||||
|
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an index without a key", async () => {
|
||||||
|
serve(URL_A, { registry: "acme" });
|
||||||
|
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||||
|
code: "NO_PUBLIC_KEY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses plain http and junk", async () => {
|
||||||
|
await expect(
|
||||||
|
registries.previewRegistry("http://example.com/index.json"),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID_URL" });
|
||||||
|
await expect(registries.previewRegistry("nope")).rejects.toMatchObject({
|
||||||
|
code: "INVALID_URL",
|
||||||
|
});
|
||||||
|
await expect(registries.previewRegistry(42)).rejects.toMatchObject({
|
||||||
|
code: "INVALID_URL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an index it cannot read", async () => {
|
||||||
|
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||||
|
code: "REGISTRY_UNREACHABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the same URL twice and the built in ones", async () => {
|
||||||
|
const key = newKey();
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: key });
|
||||||
|
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||||
|
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||||
|
code: "ALREADY_ADDED",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
registries.previewRegistry(
|
||||||
|
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json",
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "ALREADY_ADDED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never takes a reserved or used id", async () => {
|
||||||
|
const urlB = "https://example.com/b/index.json";
|
||||||
|
const urlC = "https://example.com/c/index.json";
|
||||||
|
const keyA = newKey();
|
||||||
|
const keyB = newKey();
|
||||||
|
const keyC = newKey();
|
||||||
|
serve(URL_A, { registry: "official", publicKey: keyA });
|
||||||
|
serve(urlB, { registry: "community", publicKey: keyB });
|
||||||
|
serve(urlC, { registry: "Official", publicKey: keyC });
|
||||||
|
expect((await registries.addRegistry(URL_A, pluginKeyId(keyA))).id).toBe(
|
||||||
|
"official-2",
|
||||||
|
);
|
||||||
|
expect((await registries.addRegistry(urlB, pluginKeyId(keyB))).id).toBe(
|
||||||
|
"community-2",
|
||||||
|
);
|
||||||
|
expect((await registries.addRegistry(urlC, pluginKeyId(keyC))).id).toBe(
|
||||||
|
"official-3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("turning off and removing a custom registry", () => {
|
||||||
|
async function addAcme(): Promise<string> {
|
||||||
|
const key = newKey();
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: key });
|
||||||
|
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps trusting the key of a registry that is off", async () => {
|
||||||
|
const key = await addAcme();
|
||||||
|
await registries.setRegistryEnabled("acme", false);
|
||||||
|
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||||
|
"official",
|
||||||
|
]);
|
||||||
|
expect(await registries.findSource("acme")).toBeNull();
|
||||||
|
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to remove a registry with installed plugins", async () => {
|
||||||
|
await addAcme();
|
||||||
|
state.plugins = [{ id: "tool", registryId: "acme" }];
|
||||||
|
await expect(registries.removeRegistry("acme")).rejects.toMatchObject({
|
||||||
|
code: "REGISTRY_IN_USE",
|
||||||
|
details: { plugins: ["tool"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes it and stops trusting its key", async () => {
|
||||||
|
await addAcme();
|
||||||
|
await registries.removeRegistry("acme");
|
||||||
|
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||||
|
expect(getCustomPluginKeys()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only turns built in registries off", async () => {
|
||||||
|
await expect(registries.removeRegistry("community")).rejects.toMatchObject({
|
||||||
|
code: "BUILT_IN",
|
||||||
|
});
|
||||||
|
await expect(registries.removeRegistry("nope")).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keys from a linked server", () => {
|
||||||
|
it("trusts them next to the local ones and survives a reload", async () => {
|
||||||
|
const key = newKey();
|
||||||
|
await registries.setSyncedRegistryKeys([key, "junk", 42]);
|
||||||
|
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||||
|
await registries.loadCustomRegistryKeys();
|
||||||
|
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||||
|
await registries.setSyncedRegistryKeys([]);
|
||||||
|
expect(getCustomPluginKeys()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
|||||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
// The built in official keys are swapped for the test key. Keys a
|
||||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
// caller passes for another registry are used as they are.
|
||||||
testKey.value as never,
|
verifyPluginArtifact: (
|
||||||
]),
|
buffer: Buffer,
|
||||||
|
sha256: string,
|
||||||
|
signature: string,
|
||||||
|
keys?: readonly unknown[],
|
||||||
|
) =>
|
||||||
|
actual.verifyPluginArtifact(
|
||||||
|
buffer,
|
||||||
|
sha256,
|
||||||
|
signature,
|
||||||
|
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
|
||||||
|
? (keys as never)
|
||||||
|
: [testKey.value as never],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
communitySource,
|
||||||
downloadRelease,
|
downloadRelease,
|
||||||
fetchRegistryIndex,
|
fetchRegistryIndex,
|
||||||
fetchRegistryStats,
|
fetchRegistryStats,
|
||||||
@@ -35,6 +48,7 @@ import {
|
|||||||
parseRegistryIndex,
|
parseRegistryIndex,
|
||||||
parseRegistryStats,
|
parseRegistryStats,
|
||||||
resetRegistryCache,
|
resetRegistryCache,
|
||||||
|
type RegistrySource,
|
||||||
type RegistryVersion,
|
type RegistryVersion,
|
||||||
} from "../../plugins/registry-index.js";
|
} from "../../plugins/registry-index.js";
|
||||||
|
|
||||||
@@ -91,6 +105,25 @@ afterEach(() => {
|
|||||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseRegistryIndex publicKey", () => {
|
||||||
|
const key = publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64");
|
||||||
|
|
||||||
|
it("keeps a valid key and drops anything else", () => {
|
||||||
|
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseRegistryIndex", () => {
|
describe("parseRegistryIndex", () => {
|
||||||
it("keeps valid entries and sorts versions newest first", () => {
|
it("keeps valid entries and sorts versions newest first", () => {
|
||||||
const index = parseRegistryIndex({
|
const index = parseRegistryIndex({
|
||||||
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
|
|||||||
fetchMock.mockResolvedValueOnce(response(body));
|
fetchMock.mockResolvedValueOnce(response(body));
|
||||||
const first = await fetchRegistryIndex();
|
const first = await fetchRegistryIndex();
|
||||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches each registry on its own", async () => {
|
||||||
|
const other: RegistrySource = {
|
||||||
|
id: "mine",
|
||||||
|
name: "Mine",
|
||||||
|
url: "https://example.com/mine/index.json",
|
||||||
|
tier: "custom",
|
||||||
|
keys: [],
|
||||||
|
};
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(response(body))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response(JSON.stringify({ registry: "mine", plugins: [] })),
|
||||||
|
);
|
||||||
|
const official = await fetchRegistryIndex();
|
||||||
|
const mine = await fetchRegistryIndex(other);
|
||||||
|
expect(official.plugins).toHaveLength(1);
|
||||||
|
expect(mine.plugins).toHaveLength(0);
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
|
||||||
|
expect(await fetchRegistryIndex(other)).toBe(mine);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when there is nothing cached", async () => {
|
it("throws when there is nothing cached", async () => {
|
||||||
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
|
|||||||
).rejects.toThrow(/sha256/);
|
).rejects.toThrow(/sha256/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks against the keys of the registry it came from", async () => {
|
||||||
|
const signed = sign(artifact);
|
||||||
|
const own = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const ownKey = {
|
||||||
|
id: "own",
|
||||||
|
publicKey: own.publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64"),
|
||||||
|
addedIn: "admin",
|
||||||
|
};
|
||||||
|
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||||
|
await expect(
|
||||||
|
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
|
||||||
|
ownKey,
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/trusted key/);
|
||||||
|
|
||||||
|
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||||
|
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||||
|
const result = await downloadRelease(
|
||||||
|
"docker",
|
||||||
|
version({
|
||||||
|
sha256: digest.toString("hex"),
|
||||||
|
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
|
||||||
|
size: artifact.length,
|
||||||
|
}),
|
||||||
|
[ownKey],
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a release built for another plugin API", async () => {
|
it("refuses a release built for another plugin API", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
downloadRelease("docker", version({ api: "2" })),
|
downloadRelease("docker", version({ api: "2" })),
|
||||||
@@ -407,19 +494,34 @@ describe("registry stats", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("has a community stats file and none for custom registries", () => {
|
||||||
|
expect(getStatsUrl(communitySource())).toBe(
|
||||||
|
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getStatsUrl({
|
||||||
|
id: "mine",
|
||||||
|
name: "Mine",
|
||||||
|
url: "https://example.com/index.json",
|
||||||
|
tier: "custom",
|
||||||
|
keys: [],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps valid counts and drops the rest", () => {
|
it("keeps valid counts and drops the rest", () => {
|
||||||
const stats = parseRegistryStats({
|
const stats = parseRegistryStats({
|
||||||
plugins: {
|
plugins: {
|
||||||
docker: { downloads: 120, activeInstalls: 40 },
|
docker: { downloads: 120 },
|
||||||
tunnels: { downloads: 7 },
|
tunnels: { downloads: 7 },
|
||||||
"../bad": { downloads: 1 },
|
"../bad": { downloads: 1 },
|
||||||
broken: { downloads: -3, activeInstalls: "many" },
|
broken: { downloads: -3 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||||
docker: { downloads: 120, activeInstalls: 40 },
|
docker: 120,
|
||||||
tunnels: { downloads: 7, activeInstalls: null },
|
tunnels: 7,
|
||||||
broken: { downloads: 0, activeInstalls: null },
|
broken: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,9 +530,6 @@ describe("registry stats", () => {
|
|||||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||||
);
|
);
|
||||||
const stats = await fetchRegistryStats();
|
const stats = await fetchRegistryStats();
|
||||||
expect(stats?.plugins.get("docker")).toEqual({
|
expect(stats?.plugins.get("docker")).toBe(3);
|
||||||
downloads: 3,
|
|
||||||
activeInstalls: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
|
|||||||
const testKey = vi.hoisted(() => ({
|
const testKey = vi.hoisted(() => ({
|
||||||
privateKey: null as crypto.KeyObject | null,
|
privateKey: null as crypto.KeyObject | null,
|
||||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||||
|
communityPrivateKey: null as crypto.KeyObject | null,
|
||||||
|
community: { id: "community", publicKey: "", addedIn: "26.10.1" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||||
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
|
|||||||
...actual,
|
...actual,
|
||||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||||
|
verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => {
|
||||||
|
const official = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||||
|
testKey.key,
|
||||||
|
]);
|
||||||
|
if (official.ok === true) return { ...official, tier: "official" };
|
||||||
|
const community = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||||
|
testKey.community,
|
||||||
|
]);
|
||||||
|
if (community.ok === true) return { ...community, tier: "community" };
|
||||||
|
return community;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
.export({ format: "der", type: "spki" })
|
.export({ format: "der", type: "spki" })
|
||||||
.subarray(12)
|
.subarray(12)
|
||||||
.toString("base64");
|
.toString("base64");
|
||||||
|
const community = crypto.generateKeyPairSync("ed25519");
|
||||||
|
testKey.communityPrivateKey = community.privateKey;
|
||||||
|
testKey.community.publicKey = community.publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
const cleanups: Array<() => void> = [];
|
||||||
@@ -68,7 +87,7 @@ function setup() {
|
|||||||
async function writeArtifact(
|
async function writeArtifact(
|
||||||
dir: string,
|
dir: string,
|
||||||
id: string,
|
id: string,
|
||||||
sig: "valid" | "wrong-key" | "none",
|
sig: "valid" | "community" | "wrong-key" | "none",
|
||||||
version = "1.0.0",
|
version = "1.0.0",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const build = tempRoot("termix-build-");
|
const build = tempRoot("termix-build-");
|
||||||
@@ -82,7 +101,9 @@ async function writeArtifact(
|
|||||||
const signer =
|
const signer =
|
||||||
sig === "valid"
|
sig === "valid"
|
||||||
? testKey.privateKey
|
? testKey.privateKey
|
||||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
: sig === "community"
|
||||||
|
? testKey.communityPrivateKey
|
||||||
|
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||||
const digest = crypto
|
const digest = crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
.update(fs.readFileSync(file))
|
.update(fs.readFileSync(file))
|
||||||
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
|
|||||||
expect(plugin?.source).toBe("bundled");
|
expect(plugin?.source).toBe("bundled");
|
||||||
expect(plugin?.artifact).toBe(file);
|
expect(plugin?.artifact).toBe(file);
|
||||||
expect(plugin?.signedBy).toBe("test");
|
expect(plugin?.signedBy).toBe("test");
|
||||||
|
expect(plugin?.signedTier).toBe("official");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bundled copy for a community signed newer archive", async () => {
|
||||||
|
const { userDir } = setup();
|
||||||
|
await writeArtifact(userDir, "bundled-one", "community", "2.0.0");
|
||||||
|
const loader = new PluginLoader();
|
||||||
|
await loader.loadAll();
|
||||||
|
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a community signed plugin and records its tier", async () => {
|
||||||
|
const { userDir } = setup();
|
||||||
|
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||||
|
await writeArtifact(userDir, "community-one", "community");
|
||||||
|
const loader = new PluginLoader();
|
||||||
|
await loader.loadAll();
|
||||||
|
const plugin = loader.get("community-one");
|
||||||
|
expect(plugin?.source).toBe("user");
|
||||||
|
expect(plugin?.signedBy).toBe("community");
|
||||||
|
expect(plugin?.signedTier).toBe("community");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
COMMUNITY_PLUGIN_KEYS,
|
||||||
|
TRUSTED_PLUGIN_KEYS,
|
||||||
|
isPluginPublicKey,
|
||||||
|
pluginKeyId,
|
||||||
requireSignedPlugins,
|
requireSignedPlugins,
|
||||||
|
setCustomPluginKeys,
|
||||||
|
verifyAnyTrusted,
|
||||||
verifyPluginArtifact,
|
verifyPluginArtifact,
|
||||||
type TrustedPluginKey,
|
type TrustedPluginKey,
|
||||||
} from "../../plugins/trust.js";
|
} from "../../plugins/trust.js";
|
||||||
@@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("key tiers", () => {
|
||||||
|
const artifact = Buffer.from("a plugin archive");
|
||||||
|
|
||||||
|
it("derives key ids the same way the built in keys were made", () => {
|
||||||
|
for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) {
|
||||||
|
expect(pluginKeyId(key.publicKey)).toBe(key.id);
|
||||||
|
expect(isPluginPublicKey(key.publicKey)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isPluginPublicKey("bm9wZQ==")).toBe(false);
|
||||||
|
expect(isPluginPublicKey(42)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the community key apart from the official ones", () => {
|
||||||
|
const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id));
|
||||||
|
for (const key of COMMUNITY_PLUGIN_KEYS) {
|
||||||
|
expect(official.has(key.id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a custom key only while it is set, as the custom tier", () => {
|
||||||
|
const { key, privateKey } = keyPair();
|
||||||
|
const { sha256, signature } = signed(artifact, privateKey);
|
||||||
|
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||||
|
|
||||||
|
setCustomPluginKeys([key]);
|
||||||
|
expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
keyId: "test",
|
||||||
|
tier: "custom",
|
||||||
|
});
|
||||||
|
|
||||||
|
setCustomPluginKeys([]);
|
||||||
|
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a custom key that is not a valid public key", () => {
|
||||||
|
setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]);
|
||||||
|
const { privateKey } = keyPair();
|
||||||
|
const { sha256, signature } = signed(artifact, privateKey);
|
||||||
|
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||||
|
setCustomPluginKeys([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at a sha256 mismatch", () => {
|
||||||
|
const { key, privateKey } = keyPair();
|
||||||
|
setCustomPluginKeys([key]);
|
||||||
|
const { signature } = signed(artifact, privateKey);
|
||||||
|
expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "sha256 does not match the file",
|
||||||
|
});
|
||||||
|
setCustomPluginKeys([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("requireSignedPlugins", () => {
|
describe("requireSignedPlugins", () => {
|
||||||
it("is on only for the exact value true", () => {
|
it("is on only for the exact value true", () => {
|
||||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
|
|||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
fetch: vi.fn(),
|
fetch: vi.fn(),
|
||||||
loadBundled: vi.fn(),
|
loadBundled: vi.fn(),
|
||||||
|
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
@@ -29,6 +30,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}),
|
}),
|
||||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
createCurrentPluginPermissionGrantRepository: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../../plugins/registries.js", () => ({
|
||||||
|
setSyncedRegistryKeys: state.syncedKeys,
|
||||||
|
}));
|
||||||
vi.mock("../../../sync/client/http.js", () => ({
|
vi.mock("../../../sync/client/http.js", () => ({
|
||||||
remoteFetch: state.fetch,
|
remoteFetch: state.fetch,
|
||||||
remoteJson: vi.fn(),
|
remoteJson: vi.fn(),
|
||||||
@@ -100,6 +104,19 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("mirrorPlugins", () => {
|
describe("mirrorPlugins", () => {
|
||||||
|
it("trusts the server's custom registry keys before downloading", async () => {
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [],
|
||||||
|
registryKeys: ["a-key"],
|
||||||
|
});
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.syncedKeys).toHaveBeenCalledWith(["a-key"]);
|
||||||
|
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({ plugins: [] });
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.syncedKeys).toHaveBeenLastCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the installed copy when the download fails", async () => {
|
it("keeps the installed copy when the download fails", async () => {
|
||||||
serverHas("1.1.0");
|
serverHas("1.1.0");
|
||||||
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
export const CORE_ONBOARDING_STEPS = {
|
export const CORE_ONBOARDING_STEPS = {
|
||||||
plugins: 1,
|
plugins: 1,
|
||||||
"desktop-sync": 1,
|
"desktop-sync": 1,
|
||||||
|
"desktop-updates": 1,
|
||||||
preset: 1,
|
preset: 1,
|
||||||
appearance: 1,
|
appearance: 1,
|
||||||
security: 1,
|
security: 1,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
type EditingWidth,
|
type EditingWidth,
|
||||||
} from "@/components/surface/surface-scope";
|
} from "@/components/surface/surface-scope";
|
||||||
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
||||||
|
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
|
||||||
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
|
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
|
||||||
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
|
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
|
||||||
import {
|
import {
|
||||||
@@ -3184,6 +3185,7 @@ export function AppShell({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
<OnboardingHost />
|
<OnboardingHost />
|
||||||
|
{isElectron() && <DesktopUpdateToasts />}
|
||||||
</ServerStatusProvider>
|
</ServerStatusProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,6 +221,8 @@ export interface PluginSummary {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||||
state: string;
|
state: string;
|
||||||
|
/** A linked desktop leaves this plugin to its server. */
|
||||||
|
runsOnServer?: boolean;
|
||||||
contributes: PluginContributions | null;
|
contributes: PluginContributions | null;
|
||||||
/** Lucide icon name from the manifest. */
|
/** Lucide icon name from the manifest. */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -289,8 +291,14 @@ export interface RegistryPluginVersion {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RegistryTier = "official" | "community" | "custom";
|
||||||
|
|
||||||
export interface RegistryPluginEntry {
|
export interface RegistryPluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Which registry this entry comes from. */
|
||||||
|
registryId?: string;
|
||||||
|
registryName?: string;
|
||||||
|
tier?: RegistryTier;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -313,9 +321,18 @@ export interface RegistryPluginEntry {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
/** Active installs when known, release downloads otherwise. */
|
/** How often the release files were downloaded, from the registry. */
|
||||||
installCount: number | null;
|
downloads: number | null;
|
||||||
installCountSource: string | null;
|
}
|
||||||
|
|
||||||
|
export interface RegistryListingSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: RegistryTier;
|
||||||
|
url: string;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
plugins: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryListing {
|
export interface RegistryListing {
|
||||||
@@ -325,6 +342,7 @@ export interface RegistryListing {
|
|||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
registries?: RegistryListingSource[];
|
||||||
managedByServer: boolean;
|
managedByServer: boolean;
|
||||||
plugins: RegistryPluginEntry[];
|
plugins: RegistryPluginEntry[];
|
||||||
}
|
}
|
||||||
@@ -373,7 +391,7 @@ export async function getPluginRegistry(
|
|||||||
/** `capabilities` is exactly what the consent prompt showed. */
|
/** `capabilities` is exactly what the consent prompt showed. */
|
||||||
export async function installPlugin(
|
export async function installPlugin(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
options: { version?: string; capabilities: string[] },
|
options: { version?: string; capabilities: string[]; registryId?: string },
|
||||||
): Promise<{ id: string; version: string; state: string }> {
|
): Promise<{ id: string; version: string; state: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.post(
|
const response = await rbacApi.post(
|
||||||
@@ -405,6 +423,61 @@ export async function updatePlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomRegistryInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
keyId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistriesState {
|
||||||
|
managedByServer: boolean;
|
||||||
|
official: { id: string; url: string };
|
||||||
|
community: { id: string; url: string; enabled: boolean };
|
||||||
|
custom: CustomRegistryInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistryPreview {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
keyId: string;
|
||||||
|
plugins: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegistries(): Promise<RegistriesState> {
|
||||||
|
const response = await rbacApi.get("/plugins/registries");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewRegistry(url: string): Promise<RegistryPreview> {
|
||||||
|
const response = await rbacApi.post("/plugins/registries/preview", { url });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRegistry(
|
||||||
|
url: string,
|
||||||
|
keyId: string,
|
||||||
|
): Promise<CustomRegistryInfo> {
|
||||||
|
const response = await rbacApi.post("/plugins/registries", { url, keyId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRegistryEnabled(
|
||||||
|
id: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRegistry(id: string): Promise<void> {
|
||||||
|
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.post("/plugins/update-all");
|
const response = await rbacApi.post("/plugins/update-all");
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
normalizeLanguageCode,
|
normalizeLanguageCode,
|
||||||
rememberLoginLanguage,
|
rememberLoginLanguage,
|
||||||
} from "@/i18n/i18n";
|
} from "@/i18n/i18n";
|
||||||
|
import { LANGUAGES } from "@/i18n/languages";
|
||||||
import {
|
import {
|
||||||
removeSilentSigninFromSearch,
|
removeSilentSigninFromSearch,
|
||||||
shouldTriggerSilentSignin,
|
shouldTriggerSilentSignin,
|
||||||
@@ -66,44 +67,6 @@ import {
|
|||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{ code: "en", label: "English" },
|
|
||||||
{ code: "af", label: "Afrikaans" },
|
|
||||||
{ code: "ar", label: "العربية" },
|
|
||||||
{ code: "bn", label: "বাংলা" },
|
|
||||||
{ code: "bg", label: "Български" },
|
|
||||||
{ code: "ca", label: "Català" },
|
|
||||||
{ code: "zh-CN", label: "中文 (简体)" },
|
|
||||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
|
||||||
{ code: "cs", label: "Čeština" },
|
|
||||||
{ code: "da", label: "Dansk" },
|
|
||||||
{ code: "nl", label: "Nederlands" },
|
|
||||||
{ code: "fi", label: "Suomi" },
|
|
||||||
{ code: "fr", label: "Français" },
|
|
||||||
{ code: "de", label: "Deutsch" },
|
|
||||||
{ code: "el", label: "Ελληνικά" },
|
|
||||||
{ code: "he", label: "עברית" },
|
|
||||||
{ code: "hi", label: "हिन्दी" },
|
|
||||||
{ code: "hu", label: "Magyar" },
|
|
||||||
{ code: "id", label: "Indonesia" },
|
|
||||||
{ code: "it", label: "Italiano" },
|
|
||||||
{ code: "ja", label: "日本語" },
|
|
||||||
{ code: "ko", label: "한국어" },
|
|
||||||
{ code: "no", label: "Norsk" },
|
|
||||||
{ code: "pl", label: "Polski" },
|
|
||||||
{ code: "pt-PT", label: "Português (PT)" },
|
|
||||||
{ code: "pt-BR", label: "Português (BR)" },
|
|
||||||
{ code: "ro", label: "Română" },
|
|
||||||
{ code: "ru", label: "Русский" },
|
|
||||||
{ code: "sr", label: "Српски" },
|
|
||||||
{ code: "es-ES", label: "Español" },
|
|
||||||
{ code: "sv-SE", label: "Svenska" },
|
|
||||||
{ code: "th", label: "ไทย" },
|
|
||||||
{ code: "tr", label: "Türkçe" },
|
|
||||||
{ code: "uk", label: "Українська" },
|
|
||||||
{ code: "vi", label: "Tiếng Việt" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function LanguageRow({
|
function LanguageRow({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -1138,7 +1101,9 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
port: embeddedServerFailure.port,
|
port: embeddedServerFailure.port,
|
||||||
})
|
})
|
||||||
: t("messages.embeddedServerPortInUseUnknownPort")
|
: t("messages.embeddedServerPortInUseUnknownPort")
|
||||||
: t("messages.embeddedServerCrashed");
|
: embeddedServerFailure.reason === "old-database"
|
||||||
|
? t("messages.embeddedServerOldDatabase")
|
||||||
|
: t("messages.embeddedServerCrashed");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||||
|
|||||||
@@ -3,46 +3,14 @@ import { initReactI18next } from "react-i18next";
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import enTranslation from "../locales/en.json";
|
import enTranslation from "../locales/en.json";
|
||||||
|
import { TRANSLATED_LANGUAGES } from "./languages";
|
||||||
|
|
||||||
type LocaleModule = { default: ResourceKey };
|
type LocaleModule = { default: ResourceKey };
|
||||||
|
|
||||||
/** i18n code -> translated file name, shared by core and plugin locales. */
|
/** i18n code -> translated file name, shared by core and plugin locales. */
|
||||||
export const LOCALE_FILES = {
|
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
|
||||||
af: "af_ZA",
|
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
|
||||||
ar: "ar_SA",
|
);
|
||||||
bn: "bn_BD",
|
|
||||||
bg: "bg_BG",
|
|
||||||
ca: "ca_ES",
|
|
||||||
cs: "cs_CZ",
|
|
||||||
da: "da_DK",
|
|
||||||
de: "de_DE",
|
|
||||||
el: "el_GR",
|
|
||||||
"es-ES": "es_ES",
|
|
||||||
fi: "fi_FI",
|
|
||||||
fr: "fr_FR",
|
|
||||||
he: "he_IL",
|
|
||||||
hi: "hi_IN",
|
|
||||||
hu: "hu_HU",
|
|
||||||
id: "id_ID",
|
|
||||||
it: "it_IT",
|
|
||||||
ja: "ja_JP",
|
|
||||||
ko: "ko_KR",
|
|
||||||
nl: "nl_NL",
|
|
||||||
no: "no_NO",
|
|
||||||
pl: "pl_PL",
|
|
||||||
"pt-PT": "pt_PT",
|
|
||||||
"pt-BR": "pt_BR",
|
|
||||||
ro: "ro_RO",
|
|
||||||
ru: "ru_RU",
|
|
||||||
sr: "sr_SP",
|
|
||||||
"sv-SE": "sv_SE",
|
|
||||||
th: "th_TH",
|
|
||||||
tr: "tr_TR",
|
|
||||||
uk: "uk_UA",
|
|
||||||
vi: "vi_VN",
|
|
||||||
"zh-CN": "zh_CN",
|
|
||||||
"zh-TW": "zh_TW",
|
|
||||||
} as const satisfies Record<string, string>;
|
|
||||||
|
|
||||||
const translatedFiles = import.meta.glob<LocaleModule>(
|
const translatedFiles = import.meta.glob<LocaleModule>(
|
||||||
"../locales/translated/*.json",
|
"../locales/translated/*.json",
|
||||||
@@ -94,8 +62,20 @@ export function normalizeLanguageCode(language?: string | null): string {
|
|||||||
);
|
);
|
||||||
if (exactMatch) return exactMatch;
|
if (exactMatch) return exactMatch;
|
||||||
|
|
||||||
const baseLanguage = normalized.split("-")[0];
|
const baseLanguage = normalized.split("-")[0].toLowerCase();
|
||||||
return supportedLngs.includes(baseLanguage) ? baseLanguage : "en";
|
if (supportedLngs.includes(baseLanguage)) return baseLanguage;
|
||||||
|
|
||||||
|
// Regional variants we don't ship (es-MX, pt, sv) use the closest one we do.
|
||||||
|
if (baseLanguage === "zh") {
|
||||||
|
return /-(tw|hk|mo|hant)\b/i.test(normalized) ? "zh-TW" : "zh-CN";
|
||||||
|
}
|
||||||
|
if (baseLanguage === "nb" || baseLanguage === "nn") return "no";
|
||||||
|
if (baseLanguage === "pt") return "pt-BR";
|
||||||
|
return (
|
||||||
|
supportedLngs.find((supported) =>
|
||||||
|
supported.toLowerCase().startsWith(`${baseLanguage}-`),
|
||||||
|
) ?? "en"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeBackend: BackendModule = {
|
const localeBackend: BackendModule = {
|
||||||
@@ -106,9 +86,7 @@ const localeBackend: BackendModule = {
|
|||||||
|
|
||||||
if (namespace !== CORE_NAMESPACE) {
|
if (namespace !== CORE_NAMESPACE) {
|
||||||
const file =
|
const file =
|
||||||
normalizedLanguage === "en"
|
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
|
||||||
? "en"
|
|
||||||
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
|
|
||||||
if (!pluginLocaleResolver || !file) {
|
if (!pluginLocaleResolver || !file) {
|
||||||
callback(null, {});
|
callback(null, {});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import languages from "../locales/languages.json";
|
||||||
|
|
||||||
|
/** One translated language. The translator in Termix-Registry keeps a copy of this list. */
|
||||||
|
export interface Language {
|
||||||
|
code: string;
|
||||||
|
file: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSLATED_LANGUAGES: readonly Language[] = languages;
|
||||||
|
|
||||||
|
/** Every language the app can show, English first, for language pickers. */
|
||||||
|
export const LANGUAGES: readonly { code: string; label: string }[] = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
...TRANSLATED_LANGUAGES.map(({ code, label }) => ({ code, label })),
|
||||||
|
];
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
|
||||||
|
export type DesktopUpdateMode = "automatic" | "prompt" | "never";
|
||||||
|
|
||||||
|
export type DesktopInstallKind = "auto" | "manual" | "store" | "disabled";
|
||||||
|
|
||||||
|
export type DesktopUpdateStatus =
|
||||||
|
| "idle"
|
||||||
|
| "checking"
|
||||||
|
| "available"
|
||||||
|
| "downloading"
|
||||||
|
| "downloaded"
|
||||||
|
| "up-to-date"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface DesktopUpdateState {
|
||||||
|
kind: DesktopInstallKind;
|
||||||
|
mode: DesktopUpdateMode;
|
||||||
|
currentVersion: string;
|
||||||
|
status: DesktopUpdateStatus;
|
||||||
|
version: string | null;
|
||||||
|
releaseUrl: string | null;
|
||||||
|
progress: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopUpdatesBridge {
|
||||||
|
getState: () => Promise<DesktopUpdateState | null>;
|
||||||
|
check: () => Promise<DesktopUpdateState | null>;
|
||||||
|
download: () => Promise<DesktopUpdateState | null>;
|
||||||
|
install: () => Promise<boolean>;
|
||||||
|
setMode: (mode: DesktopUpdateMode) => Promise<DesktopUpdateState | null>;
|
||||||
|
onState: (callback: (state: DesktopUpdateState) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatesWindow = Window &
|
||||||
|
typeof globalThis & {
|
||||||
|
electronAPI?: { updates?: DesktopUpdatesBridge };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The main process updater, or null in the browser and on older builds. */
|
||||||
|
export function desktopUpdates(): DesktopUpdatesBridge | null {
|
||||||
|
if (!isElectron()) return null;
|
||||||
|
return (window as UpdatesWindow).electronAPI?.updates ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this install has an updater the user can configure. */
|
||||||
|
export function canConfigureUpdates(
|
||||||
|
state: DesktopUpdateState | null,
|
||||||
|
): state is DesktopUpdateState {
|
||||||
|
return !!state && state.kind !== "store" && state.kind !== "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which toast, if any, this state calls for. */
|
||||||
|
export function updateToastFor(
|
||||||
|
state: DesktopUpdateState | null,
|
||||||
|
): "prompt" | "manual" | "ready" | null {
|
||||||
|
if (!state || !state.version || state.mode === "never") return null;
|
||||||
|
if (state.status === "downloaded") return "ready";
|
||||||
|
if (state.status !== "available") return null;
|
||||||
|
if (state.kind === "manual") return "manual";
|
||||||
|
if (state.kind === "auto" && state.mode === "prompt") return "prompt";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDesktopUpdate(): DesktopUpdateState | null {
|
||||||
|
const [state, setState] = useState<DesktopUpdateState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = desktopUpdates();
|
||||||
|
if (!bridge) return;
|
||||||
|
let active = true;
|
||||||
|
bridge
|
||||||
|
.getState()
|
||||||
|
.then((next) => active && next && setState(next))
|
||||||
|
.catch(() => {});
|
||||||
|
const off = bridge.onState((next) => active && setState(next));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
off();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -38,5 +38,7 @@
|
|||||||
"pluginCatalog": "plugins",
|
"pluginCatalog": "plugins",
|
||||||
"cli": "cli",
|
"cli": "cli",
|
||||||
"develop": "develop",
|
"develop": "develop",
|
||||||
"communityRegistry": "develop/community-registry"
|
"communityRegistry": "develop/community-registry",
|
||||||
|
"pluginRegistries": "guide/plugins#registries",
|
||||||
|
"hostRegistry": "develop/registries"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
|
|
||||||
export type EmbeddedServerFailureReason = "port-in-use" | "crashed";
|
export type EmbeddedServerFailureReason =
|
||||||
|
"port-in-use" | "old-database" | "crashed";
|
||||||
|
|
||||||
export interface EmbeddedServerFailure {
|
export interface EmbeddedServerFailure {
|
||||||
reason: EmbeddedServerFailureReason;
|
reason: EmbeddedServerFailureReason;
|
||||||
@@ -41,9 +42,13 @@ export async function getEmbeddedServerFailure(): Promise<EmbeddedServerFailure
|
|||||||
// An unrecognized reason from a newer/older main process still means the
|
// An unrecognized reason from a newer/older main process still means the
|
||||||
// backend is down, which is the part the caller acts on -- so it is
|
// backend is down, which is the part the caller acts on -- so it is
|
||||||
// reported as the generic crash rather than dropped.
|
// reported as the generic crash rather than dropped.
|
||||||
return failure.reason === "port-in-use"
|
if (failure.reason === "port-in-use") {
|
||||||
? { reason: "port-in-use", port: failure.port ?? null }
|
return { reason: "port-in-use", port: failure.port ?? null };
|
||||||
: { reason: "crashed", port: null };
|
}
|
||||||
|
if (failure.reason === "old-database") {
|
||||||
|
return { reason: "old-database", port: null };
|
||||||
|
}
|
||||||
|
return { reason: "crashed", port: null };
|
||||||
} catch {
|
} catch {
|
||||||
// No status channel, or the main process is tearing down. Neither is
|
// No status channel, or the main process is tearing down. Neither is
|
||||||
// evidence that the backend failed, so keep waiting.
|
// evidence that the backend failed, so keep waiting.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Translations
|
# Translations
|
||||||
|
|
||||||
Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet.
|
Only `en.json` needs to be updated. Every other language in `translated/` is translated automatically on each release (and by the Translate workflow) with the Termix LLM server, using the translator in [Termix-Registry](https://github.com/Termix-SSH/Termix-Registry/tree/main/translate).
|
||||||
|
|
||||||
If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten.
|
New strings and strings whose English changed are translated. `translations-lock.json` tracks which English text each translation was made from, so don't edit it by hand.
|
||||||
|
|
||||||
|
If an automatic translation is wrong, fix it in that language's file. A fixed string is kept until its English changes.
|
||||||
|
|
||||||
|
To add a language, add it to `languages.json` here and in `translate/languages.json` in Termix-Registry.
|
||||||
@@ -233,6 +233,37 @@
|
|||||||
"desktopLinked": "Linked to {{server}}",
|
"desktopLinked": "Linked to {{server}}",
|
||||||
"pluginsDocsLink": "Read about each one"
|
"pluginsDocsLink": "Read about each one"
|
||||||
},
|
},
|
||||||
|
"desktopUpdates": {
|
||||||
|
"title": "Desktop app",
|
||||||
|
"mode": "Updates",
|
||||||
|
"modeHint": "How this app gets new versions.",
|
||||||
|
"manualModeHint": "This copy can not update itself, so Termix only tells you when a new version is out.",
|
||||||
|
"modeAutomatic": "Automatic",
|
||||||
|
"modePrompt": "Ask first",
|
||||||
|
"modeNever": "Never",
|
||||||
|
"automaticBody": "Download new versions in the background and install them when you restart.",
|
||||||
|
"promptBody": "Tell me when a new version is out and let me choose when to download it.",
|
||||||
|
"neverBody": "Do not check for updates. You can still check by hand in Settings.",
|
||||||
|
"onboardingTitle": "App updates",
|
||||||
|
"onboardingBody": "The desktop app can now update itself. Pick how you want new versions to arrive. You can change this later in Settings, Updates.",
|
||||||
|
"onboardingManual": "This copy of the app was installed in a way that can not update itself, so Termix will tell you when a new version is out with a link to download it.",
|
||||||
|
"status": "Version {{version}}",
|
||||||
|
"statusIdle": "Not checked yet",
|
||||||
|
"statusChecking": "Checking for updates",
|
||||||
|
"statusAvailable": "Version {{version}} is available",
|
||||||
|
"statusDownloading": "Downloading {{version}} ({{progress}}%)",
|
||||||
|
"statusReady": "Version {{version}} is ready. Restart to install it.",
|
||||||
|
"statusUpToDate": "You have the newest version",
|
||||||
|
"statusError": "Could not check for updates. Try again later.",
|
||||||
|
"checkNow": "Check now",
|
||||||
|
"download": "Download",
|
||||||
|
"restart": "Restart now",
|
||||||
|
"later": "Later",
|
||||||
|
"available": "Termix {{version}} is available",
|
||||||
|
"manualHint": "Download it from the release page and install it over this one.",
|
||||||
|
"ready": "Termix {{version}} is ready to install",
|
||||||
|
"readyHint": "It installs the next time Termix restarts."
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
@@ -280,7 +311,8 @@
|
|||||||
},
|
},
|
||||||
"searchEverything": "Search hosts, tabs and actions",
|
"searchEverything": "Search hosts, tabs and actions",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"hiddenCount": "{{count}} hidden",
|
"hiddenCount_one": "{{count}} hidden",
|
||||||
|
"hiddenCount_other": "{{count}} hidden",
|
||||||
"showInRail": "Show in sidebar",
|
"showInRail": "Show in sidebar",
|
||||||
"moveUp": "Move up",
|
"moveUp": "Move up",
|
||||||
"moveDown": "Move down",
|
"moveDown": "Move down",
|
||||||
@@ -344,7 +376,8 @@
|
|||||||
"unsavedChangesDescription": "Your host changes have not been saved. Discard them?",
|
"unsavedChangesDescription": "Your host changes have not been saved. Discard them?",
|
||||||
"keepEditing": "Keep editing",
|
"keepEditing": "Keep editing",
|
||||||
"discardChanges": "Discard changes",
|
"discardChanges": "Discard changes",
|
||||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
"movedToFolder_one": "Moved {{count}} host to \"{{folder}}\"",
|
||||||
|
"movedToFolder_other": "Moved {{count}} hosts to \"{{folder}}\"",
|
||||||
"editHostTooltip": "Edit host",
|
"editHostTooltip": "Edit host",
|
||||||
"behavior": "Behavior",
|
"behavior": "Behavior",
|
||||||
"scrollbackBuffer": "Scrollback Buffer",
|
"scrollbackBuffer": "Scrollback Buffer",
|
||||||
@@ -545,7 +578,8 @@
|
|||||||
"collapseSubHosts": "Collapse sub-hosts",
|
"collapseSubHosts": "Collapse sub-hosts",
|
||||||
"expandSubHosts": "Expand sub-hosts",
|
"expandSubHosts": "Expand sub-hosts",
|
||||||
"cannotNestUnderDescendant": "Can't nest a host under its own sub-host",
|
"cannotNestUnderDescendant": "Can't nest a host under its own sub-host",
|
||||||
"movedToParent": "Moved {{count}} host(s) under \"{{parent}}\"",
|
"movedToParent_one": "Moved {{count}} host under \"{{parent}}\"",
|
||||||
|
"movedToParent_other": "Moved {{count}} hosts under \"{{parent}}\"",
|
||||||
"newFolder": "New folder",
|
"newFolder": "New folder",
|
||||||
"createFolderTitle": "Create folder",
|
"createFolderTitle": "Create folder",
|
||||||
"editFolderTitle": "Edit folder",
|
"editFolderTitle": "Edit folder",
|
||||||
@@ -573,7 +607,8 @@
|
|||||||
"folderSaved": "Folder saved",
|
"folderSaved": "Folder saved",
|
||||||
"failedToSaveFolder": "Failed to save folder",
|
"failedToSaveFolder": "Failed to save folder",
|
||||||
"folderDeleted": "Deleted folder \"{{name}}\"",
|
"folderDeleted": "Deleted folder \"{{name}}\"",
|
||||||
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
|
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
|
||||||
|
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
|
||||||
"failedToMoveHosts": "Failed to move hosts",
|
"failedToMoveHosts": "Failed to move hosts",
|
||||||
"failedToReorder": "Failed to reorder",
|
"failedToReorder": "Failed to reorder",
|
||||||
"expandAll": "Expand all folders",
|
"expandAll": "Expand all folders",
|
||||||
@@ -641,7 +676,8 @@
|
|||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
|
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
|
||||||
"deletedCredential": "Deleted {{name}}",
|
"deletedCredential": "Deleted {{name}}",
|
||||||
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
|
"deleteHostsConfirm_one": "Delete {{count}} host? This cannot be undone.",
|
||||||
|
"deleteHostsConfirm_other": "Delete {{count}} hosts? This cannot be undone.",
|
||||||
"movedToRoot": "Moved to root",
|
"movedToRoot": "Moved to root",
|
||||||
"addTagsPlaceholder": "Add tags...",
|
"addTagsPlaceholder": "Add tags...",
|
||||||
"authDetails": "Authentication Details",
|
"authDetails": "Authentication Details",
|
||||||
@@ -700,7 +736,8 @@
|
|||||||
"groupFeatureFlags": "Plugin settings",
|
"groupFeatureFlags": "Plugin settings",
|
||||||
"groupAdvanced": "Advanced config",
|
"groupAdvanced": "Advanced config",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"moreHosts": "... {{count}} more hosts",
|
"moreHosts_one": "... {{count}} more host",
|
||||||
|
"moreHosts_other": "... {{count}} more hosts",
|
||||||
"summary": "{{selected}} of {{total}} hosts",
|
"summary": "{{selected}} of {{total}} hosts",
|
||||||
"credentialsIncluded": "credentials included",
|
"credentialsIncluded": "credentials included",
|
||||||
"credentialsExcluded": "credentials excluded",
|
"credentialsExcluded": "credentials excluded",
|
||||||
@@ -713,16 +750,20 @@
|
|||||||
"sampleDownloaded": "Sample file downloaded",
|
"sampleDownloaded": "Sample file downloaded",
|
||||||
"failedToDeleteCredential2": "Failed to delete credential",
|
"failedToDeleteCredential2": "Failed to delete credential",
|
||||||
"noFolderOption": "(No folder)",
|
"noFolderOption": "(No folder)",
|
||||||
"nSelected": "{{count}} selected",
|
"nSelected_one": "{{count}} selected",
|
||||||
|
"nSelected_other": "{{count}} selected",
|
||||||
"featuresMenu": "Plugins",
|
"featuresMenu": "Plugins",
|
||||||
"moveMenu": "Move",
|
"moveMenu": "Move",
|
||||||
"connectSelected": "Connect",
|
"connectSelected": "Connect",
|
||||||
"cancelSelection": "Cancel",
|
"cancelSelection": "Cancel",
|
||||||
"deletedCount": "Deleted {{count}} hosts",
|
"deletedCount_one": "Deleted {{count}} host",
|
||||||
"failedToDeleteCount": "Failed to delete {{count}} hosts",
|
"deletedCount_other": "Deleted {{count}} hosts",
|
||||||
|
"failedToDeleteCount_one": "Failed to delete {{count}} host",
|
||||||
|
"failedToDeleteCount_other": "Failed to delete {{count}} hosts",
|
||||||
"duplicatedHost": "Duplicated \"{{name}}\"",
|
"duplicatedHost": "Duplicated \"{{name}}\"",
|
||||||
"failedToDuplicateHost": "Failed to duplicate host",
|
"failedToDuplicateHost": "Failed to duplicate host",
|
||||||
"updatedCount": "Updated {{count}} hosts",
|
"updatedCount_one": "Updated {{count}} host",
|
||||||
|
"updatedCount_other": "Updated {{count}} hosts",
|
||||||
"friendlyNameLabel": "Friendly Name",
|
"friendlyNameLabel": "Friendly Name",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"loadingHosts": "Loading hosts...",
|
"loadingHosts": "Loading hosts...",
|
||||||
@@ -762,7 +803,8 @@
|
|||||||
"shareHostTitle": "Share: {{name}}",
|
"shareHostTitle": "Share: {{name}}",
|
||||||
"shareFolder": "Share Folder",
|
"shareFolder": "Share Folder",
|
||||||
"shareFolderTitle": "Share folder: {{name}}",
|
"shareFolderTitle": "Share folder: {{name}}",
|
||||||
"folderSharedSuccessfully": "Shared {{count}} host(s) in folder",
|
"folderSharedSuccessfully_one": "Shared {{count}} host in folder",
|
||||||
|
"folderSharedSuccessfully_other": "Shared {{count}} hosts in folder",
|
||||||
"failedToShareFolder": "Failed to share folder",
|
"failedToShareFolder": "Failed to share folder",
|
||||||
"sharing": {
|
"sharing": {
|
||||||
"loadError": "Failed to load sharing data. Please try again.",
|
"loadError": "Failed to load sharing data. Please try again.",
|
||||||
@@ -921,7 +963,8 @@
|
|||||||
"unsplit": "Unsplit",
|
"unsplit": "Unsplit",
|
||||||
"layouts": "Layouts",
|
"layouts": "Layouts",
|
||||||
"applyLayout": "Change layout",
|
"applyLayout": "Change layout",
|
||||||
"maxPanes": "A split can hold up to {{count}} panes",
|
"maxPanes_one": "A split can hold up to {{count}} pane",
|
||||||
|
"maxPanes_other": "A split can hold up to {{count}} panes",
|
||||||
"dropSplit": "Drop to split",
|
"dropSplit": "Drop to split",
|
||||||
"dropHere": "Drop to show here",
|
"dropHere": "Drop to show here",
|
||||||
"paneActions": "Pane actions",
|
"paneActions": "Pane actions",
|
||||||
@@ -1084,6 +1127,7 @@
|
|||||||
"databaseConnectionFailed": "Failed to connect to the database server",
|
"databaseConnectionFailed": "Failed to connect to the database server",
|
||||||
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
||||||
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
||||||
|
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
|
||||||
"embeddedServerCrashed": "The local server stopped unexpectedly. Restart Termix, and check the desktop log if this keeps happening.",
|
"embeddedServerCrashed": "The local server stopped unexpectedly. Restart Termix, and check the desktop log if this keeps happening.",
|
||||||
"resetCodeSent": "Reset code sent to Docker logs",
|
"resetCodeSent": "Reset code sent to Docker logs",
|
||||||
"codeVerified": "Code verified successfully",
|
"codeVerified": "Code verified successfully",
|
||||||
@@ -1237,7 +1281,8 @@
|
|||||||
"statusCheck": "Status Check",
|
"statusCheck": "Status Check",
|
||||||
"sec": "sec",
|
"sec": "sec",
|
||||||
"logLevel": "Log Level",
|
"logLevel": "Log Level",
|
||||||
"usersCount": "{{count}} users",
|
"usersCount_one": "{{count}} user",
|
||||||
|
"usersCount_other": "{{count}} users",
|
||||||
"searchUsers": "Search users...",
|
"searchUsers": "Search users...",
|
||||||
"noUsers": "No users",
|
"noUsers": "No users",
|
||||||
"noUsersMatch": "No users match your search",
|
"noUsersMatch": "No users match your search",
|
||||||
@@ -1247,7 +1292,8 @@
|
|||||||
"roleName": "Name",
|
"roleName": "Name",
|
||||||
"roleDisplayName": "Display Name",
|
"roleDisplayName": "Display Name",
|
||||||
"roleDescription": "Description",
|
"roleDescription": "Description",
|
||||||
"rolesCount": "{{count}} roles",
|
"rolesCount_one": "{{count}} role",
|
||||||
|
"rolesCount_other": "{{count}} roles",
|
||||||
"createRole": "Create",
|
"createRole": "Create",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
"exportDatabase": "Export Database",
|
"exportDatabase": "Export Database",
|
||||||
@@ -1261,7 +1307,8 @@
|
|||||||
"changeFile": "Change",
|
"changeFile": "Change",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"apiKeysCount": "{{count}} keys",
|
"apiKeysCount_one": "{{count}} key",
|
||||||
|
"apiKeysCount_other": "{{count}} keys",
|
||||||
"apiKeysDocsLink": "View docs",
|
"apiKeysDocsLink": "View docs",
|
||||||
"apiKeysCliHint": "Use an API key with the Termix CLI to manage servers from your terminal.",
|
"apiKeysCliHint": "Use an API key with the Termix CLI to manage servers from your terminal.",
|
||||||
"apiKeysCliLink": "View CLI docs",
|
"apiKeysCliLink": "View CLI docs",
|
||||||
@@ -1283,7 +1330,8 @@
|
|||||||
"systemBadge": "SYS",
|
"systemBadge": "SYS",
|
||||||
"customBadge": "CUSTOM",
|
"customBadge": "CUSTOM",
|
||||||
"youBadge": "YOU",
|
"youBadge": "YOU",
|
||||||
"sessionsActive": "{{count}} active",
|
"sessionsActive_one": "{{count}} active",
|
||||||
|
"sessionsActive_other": "{{count}} active",
|
||||||
"sessionActive": "Active: {{time}}",
|
"sessionActive": "Active: {{time}}",
|
||||||
"sessionExpires": "Exp: {{time}}",
|
"sessionExpires": "Exp: {{time}}",
|
||||||
"revokeAll": "All",
|
"revokeAll": "All",
|
||||||
@@ -1392,14 +1440,16 @@
|
|||||||
"exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.",
|
"exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.",
|
||||||
"exportUserDataSuccess": "User data exported",
|
"exportUserDataSuccess": "User data exported",
|
||||||
"exportUserDataFailed": "Failed to export user data",
|
"exportUserDataFailed": "Failed to export user data",
|
||||||
"hostsCount": "{{count}} hosts",
|
"hostsCount_one": "{{count}} host",
|
||||||
|
"hostsCount_other": "{{count}} hosts",
|
||||||
"addHostForUser": "Add Host",
|
"addHostForUser": "Add Host",
|
||||||
"noHostsForUser": "This user has no hosts",
|
"noHostsForUser": "This user has no hosts",
|
||||||
"connectToHost": "Connect",
|
"connectToHost": "Connect",
|
||||||
"deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?",
|
"deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?",
|
||||||
"hostDeletedSuccess": "Host deleted",
|
"hostDeletedSuccess": "Host deleted",
|
||||||
"hostDeleteFailed": "Failed to delete host",
|
"hostDeleteFailed": "Failed to delete host",
|
||||||
"credentialsCount": "{{count}} credentials",
|
"credentialsCount_one": "{{count}} credential",
|
||||||
|
"credentialsCount_other": "{{count}} credentials",
|
||||||
"addCredentialForUser": "Add Credential",
|
"addCredentialForUser": "Add Credential",
|
||||||
"noCredentialsForUser": "This user has no credentials",
|
"noCredentialsForUser": "This user has no credentials",
|
||||||
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
|
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
|
||||||
@@ -1428,7 +1478,8 @@
|
|||||||
"importError": "Database import failed",
|
"importError": "Database import failed",
|
||||||
"sectionHostDefaults": "Host Defaults",
|
"sectionHostDefaults": "Host Defaults",
|
||||||
"rolePermissions": {
|
"rolePermissions": {
|
||||||
"count": "{{count}} permissions",
|
"count_one": "{{count}} permission",
|
||||||
|
"count_other": "{{count}} permissions",
|
||||||
"editAction": "Edit permissions",
|
"editAction": "Edit permissions",
|
||||||
"loadError": "Failed to load the permissions catalog",
|
"loadError": "Failed to load the permissions catalog",
|
||||||
"saved": "Role permissions saved",
|
"saved": "Role permissions saved",
|
||||||
@@ -1647,7 +1698,8 @@
|
|||||||
"apiKeyCreatedWarning": "Key created - copy it now, it won't be shown again.",
|
"apiKeyCreatedWarning": "Key created - copy it now, it won't be shown again.",
|
||||||
"copyApiKey": "Copy API key",
|
"copyApiKey": "Copy API key",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
"apiKeyCount": "{{count}} keys",
|
"apiKeyCount_one": "{{count}} key",
|
||||||
|
"apiKeyCount_other": "{{count}} keys",
|
||||||
"newKey": "New Key",
|
"newKey": "New Key",
|
||||||
"noApiKeys": "No API keys yet.",
|
"noApiKeys": "No API keys yet.",
|
||||||
"apiKeyActive": "Active",
|
"apiKeyActive": "Active",
|
||||||
@@ -1848,19 +1900,23 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"installed": "Installed",
|
"installed": "Installed",
|
||||||
"browse": "Browse",
|
"browse": "Browse",
|
||||||
"updates": "Updates"
|
"updates": "Updates",
|
||||||
|
"registries": "Registries"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"summary": "{{installed}} installed, {{running}} running",
|
"summary": "{{installed}} installed, {{running}} running",
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"failed": "Stopped unexpectedly",
|
"failed": "Stopped unexpectedly",
|
||||||
"blocked": "Waiting on another plugin"
|
"blocked": "Waiting on another plugin",
|
||||||
|
"server": "Runs on your server"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"notRunning": "Not running"
|
"notRunning": "Not running",
|
||||||
|
"onServer": "On your server"
|
||||||
},
|
},
|
||||||
|
"runsOnServerHint": "This plugin only runs on your server, so it stays off in the desktop app. It still works there.",
|
||||||
"sort": {
|
"sort": {
|
||||||
"popular": "Most popular",
|
"popular": "Most popular",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -1878,7 +1934,7 @@
|
|||||||
"version": "Version",
|
"version": "Version",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"installs": "Installs"
|
"downloads": "Downloads"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"critical": "Critical",
|
"critical": "Critical",
|
||||||
@@ -2030,8 +2086,47 @@
|
|||||||
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
||||||
"nothingHere": "Nothing here.",
|
"nothingHere": "Nothing here.",
|
||||||
"nothingHereHint": "Try another category or search.",
|
"nothingHereHint": "Try another category or search.",
|
||||||
"communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.",
|
"showInstalled": "Show installed",
|
||||||
|
"allInstalled": "Everything here is already installed.",
|
||||||
|
"allInstalledHint": "Turn on Show installed to see them.",
|
||||||
"communitySubmit": "How to submit",
|
"communitySubmit": "How to submit",
|
||||||
|
"registries": {
|
||||||
|
"intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.",
|
||||||
|
"hostYourOwn": "Host your own registry",
|
||||||
|
"add": "Add registry",
|
||||||
|
"addTitle": "Add a registry",
|
||||||
|
"addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.",
|
||||||
|
"urlLabel": "Index URL",
|
||||||
|
"check": "Check",
|
||||||
|
"addConfirm": "Trust and add",
|
||||||
|
"added": "Added {{name}}",
|
||||||
|
"customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.",
|
||||||
|
"keyFingerprint": "Key {{keyId}}",
|
||||||
|
"pluginCount_one": "{{count}} plugin",
|
||||||
|
"pluginCount_other": "{{count}} plugins",
|
||||||
|
"officialName": "Official",
|
||||||
|
"communityName": "Community",
|
||||||
|
"alwaysOn": "Always on",
|
||||||
|
"communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.",
|
||||||
|
"communityConfirmTitle": "Turn on community plugins?",
|
||||||
|
"communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.",
|
||||||
|
"communityConfirm": "Turn on",
|
||||||
|
"communityBadge": "Community",
|
||||||
|
"communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.",
|
||||||
|
"customBadge": "Custom",
|
||||||
|
"customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removeTitle": "Remove {{name}}?",
|
||||||
|
"removeBody": "Its plugins will no longer show in Browse. You can add it again later.",
|
||||||
|
"removed": "Removed {{name}}",
|
||||||
|
"inUse": "Uninstall the plugins from {{name}} first: {{list}}",
|
||||||
|
"changeFailed": "Could not change the registry: {{error}}",
|
||||||
|
"loadFailed": "Could not load registries: {{error}}",
|
||||||
|
"unreachable": "Could not read it: {{error}}",
|
||||||
|
"filter": "Registry",
|
||||||
|
"allRegistries": "All registries",
|
||||||
|
"browseHint": "Turn on community plugins or add your own registry in the Registries tab."
|
||||||
|
},
|
||||||
"upToDate": "Everything is up to date.",
|
"upToDate": "Everything is up to date.",
|
||||||
"lastChecked": "Last checked {{time}}",
|
"lastChecked": "Last checked {{time}}",
|
||||||
"updatesReady_one": "{{count}} update ready.",
|
"updatesReady_one": "{{count}} update ready.",
|
||||||
@@ -2054,11 +2149,8 @@
|
|||||||
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
||||||
},
|
},
|
||||||
"count": {
|
"count": {
|
||||||
"active_one": "{{formatted}} active install",
|
|
||||||
"active_other": "{{formatted}} active installs",
|
|
||||||
"downloads_one": "{{formatted}} download",
|
"downloads_one": "{{formatted}} download",
|
||||||
"downloads_other": "{{formatted}} downloads",
|
"downloads_other": "{{formatted}} downloads",
|
||||||
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
|
|
||||||
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
||||||
},
|
},
|
||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
|
|||||||