mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: changelog.md release notes, v tags, plugin release notes and videos
This commit is contained in:
1 parent
b9dd3488a5
commit
9bc2be721b
45 files changed
+1462
-816
No files matched your search
@@ -355,8 +355,8 @@ jobs:
|
|||||||
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
|
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
|
||||||
|
|
||||||
cd flatpak-build
|
cd flatpak-build
|
||||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
|
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
|
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
|
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
|
||||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
|
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
|
||||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
|
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
|
||||||
@@ -382,7 +382,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
||||||
cp packaging/flatpak/com.karmaa.termix.flatpakref release/
|
cp packaging/flatpak/com.karmaa.termix.flatpakref release/
|
||||||
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
|
sed -i "s|VERSION_PLACEHOLDER|v${VERSION}|g" release/com.karmaa.termix.flatpakref
|
||||||
|
|
||||||
- name: Upload Flatpak bundle
|
- name: Upload Flatpak bundle
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -618,7 +618,7 @@ jobs:
|
|||||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
|
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
|
||||||
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
|
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
|
||||||
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
|
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
|
||||||
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
|
sed -i '' "s|download/[^/]*/termix_macos|download/v$VERSION/termix_macos|g" homebrew-generated/termix.rb
|
||||||
|
|
||||||
- name: Upload Homebrew Cask as artifact
|
- name: Upload Homebrew Cask as artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -634,7 +634,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||||
RELEASE_TAG="release-$VERSION-tag"
|
RELEASE_TAG="v$VERSION"
|
||||||
|
|
||||||
gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
|
gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
|
||||||
echo "Release $RELEASE_TAG not found"
|
echo "Release $RELEASE_TAG not found"
|
||||||
@@ -673,7 +673,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$VERSION = "${{ steps.package-version.outputs.version }}"
|
$VERSION = "${{ steps.package-version.outputs.version }}"
|
||||||
$MSI_NAME = "termix_windows_x64_msi.msi"
|
$MSI_NAME = "termix_windows_x64_msi.msi"
|
||||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)"
|
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/v$($VERSION)/$($MSI_NAME)"
|
||||||
|
|
||||||
Write-Host "Downloading from $DOWNLOAD_URL"
|
Write-Host "Downloading from $DOWNLOAD_URL"
|
||||||
New-Item -ItemType Directory -Force -Path "release_asset"
|
New-Item -ItemType Directory -Force -Path "release_asset"
|
||||||
@@ -696,7 +696,7 @@ jobs:
|
|||||||
$CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
|
$CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
|
||||||
$MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"
|
$MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"
|
||||||
|
|
||||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"
|
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$MSI_NAME"
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path "choco-build"
|
New-Item -ItemType Directory -Force -Path "choco-build"
|
||||||
Copy-Item -Path "packaging\chocolatey\*" -Destination "choco-build" -Recurse -Force
|
Copy-Item -Path "packaging\chocolatey\*" -Destination "choco-build" -Recurse -Force
|
||||||
@@ -784,7 +784,7 @@ jobs:
|
|||||||
mkdir -p release_assets
|
mkdir -p release_assets
|
||||||
|
|
||||||
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
|
APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
|
||||||
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
|
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_X64_NAME"
|
||||||
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
|
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
|
||||||
echo "Downloading x64 AppImage from $URL_X64"
|
echo "Downloading x64 AppImage from $URL_X64"
|
||||||
curl -L -o "$PATH_X64" "$URL_X64"
|
curl -L -o "$PATH_X64" "$URL_X64"
|
||||||
@@ -792,7 +792,7 @@ jobs:
|
|||||||
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
|
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
|
||||||
|
|
||||||
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
|
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
|
||||||
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
|
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_ARM64_NAME"
|
||||||
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
|
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
|
||||||
echo "Downloading arm64 AppImage from $URL_ARM64"
|
echo "Downloading arm64 AppImage from $URL_ARM64"
|
||||||
curl -L -o "$PATH_ARM64" "$URL_ARM64"
|
curl -L -o "$PATH_ARM64" "$URL_ARM64"
|
||||||
@@ -918,7 +918,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.package-version.outputs.version }}"
|
VERSION="${{ steps.package-version.outputs.version }}"
|
||||||
DMG_NAME="termix_macos_universal_dmg.dmg"
|
DMG_NAME="termix_macos_universal_dmg.dmg"
|
||||||
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
URL="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$DMG_NAME"
|
||||||
|
|
||||||
mkdir -p release_asset
|
mkdir -p release_asset
|
||||||
DOWNLOAD_PATH="release_asset/$DMG_NAME"
|
DOWNLOAD_PATH="release_asset/$DMG_NAME"
|
||||||
@@ -1191,7 +1191,7 @@ jobs:
|
|||||||
META_DIR="$RUNNER_TEMP/asc_metadata"
|
META_DIR="$RUNNER_TEMP/asc_metadata"
|
||||||
rm -rf "$META_DIR"
|
rm -rf "$META_DIR"
|
||||||
node scripts/generate-appstore-notes.cjs \
|
node scripts/generate-appstore-notes.cjs \
|
||||||
--notes RELEASE_NOTES.md \
|
--changelog CHANGELOG.md \
|
||||||
--out-dir "$META_DIR" \
|
--out-dir "$META_DIR" \
|
||||||
--locales "en-US"
|
--locales "en-US"
|
||||||
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
|
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
version: ${{ steps.info.outputs.version }}
|
version: ${{ steps.info.outputs.version }}
|
||||||
release_tag: ${{ steps.info.outputs.release_tag }}
|
release_tag: ${{ steps.info.outputs.release_tag }}
|
||||||
mobile_version: ${{ steps.info.outputs.mobile_version }}
|
mobile_version: ${{ steps.info.outputs.mobile_version }}
|
||||||
|
mobile_tag: ${{ steps.info.outputs.mobile_tag }}
|
||||||
dev_branch: ${{ steps.info.outputs.dev_branch }}
|
dev_branch: ${{ steps.info.outputs.dev_branch }}
|
||||||
steps:
|
steps:
|
||||||
- name: Guard branch (release modes)
|
- name: Guard branch (release modes)
|
||||||
@@ -62,22 +63,23 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
|
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"
|
echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName)
|
MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName)
|
||||||
MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//')
|
MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//; s/^v//')
|
||||||
if [ -z "$MOBILE_VERSION" ]; then
|
if [ -z "$MOBILE_VERSION" ]; then
|
||||||
echo "Failed to resolve the latest Termix-SSH/Mobile release version."
|
echo "Failed to resolve the latest Termix-SSH/Mobile release version."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT"
|
echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "mobile_tag=$MOBILE_RAW" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Validate release notes
|
- name: Check CHANGELOG.md has notes for this version
|
||||||
run: |
|
run: |
|
||||||
node scripts/generate-release-body.cjs \
|
node scripts/generate-release-body.cjs \
|
||||||
--version "${{ steps.info.outputs.version }}" \
|
--version "${{ steps.info.outputs.version }}" \
|
||||||
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
|
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
|
||||||
--notes RELEASE_NOTES.md > /dev/null
|
--mobile-tag "${{ steps.info.outputs.mobile_tag }}" > /dev/null
|
||||||
|
|
||||||
normalize:
|
normalize:
|
||||||
needs: [prep]
|
needs: [prep]
|
||||||
@@ -242,7 +244,7 @@ jobs:
|
|||||||
node scripts/generate-release-body.cjs \
|
node scripts/generate-release-body.cjs \
|
||||||
--version "${{ needs.prep.outputs.version }}" \
|
--version "${{ needs.prep.outputs.version }}" \
|
||||||
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
||||||
--notes RELEASE_NOTES.md > PR_BODY.md
|
--mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > PR_BODY.md
|
||||||
|
|
||||||
- name: Open and squash-merge PR to main
|
- name: Open and squash-merge PR to main
|
||||||
id: merge
|
id: merge
|
||||||
@@ -250,7 +252,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
|
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
|
||||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
TITLE="v${{ needs.prep.outputs.version }}"
|
||||||
|
|
||||||
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
|
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
|
||||||
echo "OVERWRITE: no merge; building from main as-is."
|
echo "OVERWRITE: no merge; building from main as-is."
|
||||||
@@ -336,7 +338,7 @@ jobs:
|
|||||||
node scripts/generate-release-body.cjs \
|
node scripts/generate-release-body.cjs \
|
||||||
--version "${{ needs.prep.outputs.version }}" \
|
--version "${{ needs.prep.outputs.version }}" \
|
||||||
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
||||||
--notes RELEASE_NOTES.md > RELEASE_BODY.md
|
--mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > RELEASE_BODY.md
|
||||||
|
|
||||||
- name: Create or update GitHub release
|
- name: Create or update GitHub release
|
||||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||||
@@ -344,7 +346,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
TITLE="v${{ needs.prep.outputs.version }}"
|
||||||
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
||||||
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
|
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
|
||||||
else
|
else
|
||||||
@@ -504,7 +506,7 @@ jobs:
|
|||||||
if [ -z "$PR_NUMBER" ]; then
|
if [ -z "$PR_NUMBER" ]; then
|
||||||
PR_URL=$(gh pr create --repo Termix-SSH/Docs \
|
PR_URL=$(gh pr create --repo Termix-SSH/Docs \
|
||||||
--base main --head "$BRANCH" \
|
--base main --head "$BRANCH" \
|
||||||
--title "release-${{ needs.prep.outputs.version }}" \
|
--title "v${{ needs.prep.outputs.version }}" \
|
||||||
--body "API docs for ${{ needs.prep.outputs.version }}")
|
--body "API docs for ${{ needs.prep.outputs.version }}")
|
||||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||||
fi
|
fi
|
||||||
@@ -516,39 +518,8 @@ jobs:
|
|||||||
|
|
||||||
gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin
|
gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin
|
||||||
|
|
||||||
publish-youtube:
|
|
||||||
needs: [prep, electron-release]
|
|
||||||
if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }}
|
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout main
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version-file: ".nvmrc"
|
|
||||||
|
|
||||||
- name: Set release video to public
|
|
||||||
env:
|
|
||||||
YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
|
|
||||||
YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
|
|
||||||
YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
|
|
||||||
run: node scripts/publish-youtube.cjs
|
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
needs:
|
needs: [prep, merge-to-main, electron-release, cask-commit-back, docs]
|
||||||
[
|
|
||||||
prep,
|
|
||||||
merge-to-main,
|
|
||||||
electron-release,
|
|
||||||
cask-commit-back,
|
|
||||||
docs,
|
|
||||||
publish-youtube,
|
|
||||||
]
|
|
||||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }}
|
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }}
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading
|
||||||
+1
-1
@@ -2,7 +2,7 @@ cask "termix" do
|
|||||||
version "2.9.1"
|
version "2.9.1"
|
||||||
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
|
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
|
||||||
|
|
||||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||||
name "Termix"
|
name "Termix"
|
||||||
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
|
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
|
||||||
homepage "https://github.com/Termix-SSH/Termix"
|
homepage "https://github.com/Termix-SSH/Termix"
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<!-- SUMMARY -->
|
|
||||||
|
|
||||||
A patch for 2.9.0 that fixes OIDC sign-in after upgrading, Docker and Host Metrics logins, memory and CPU use, file downloads, tmux sessions, user data export and more. Also adds predefined host tags.
|
|
||||||
|
|
||||||
<!-- /SUMMARY -->
|
|
||||||
|
|
||||||
<!-- YOUTUBE -->
|
|
||||||
|
|
||||||
https://youtu.be/lngaePO96tM
|
|
||||||
|
|
||||||
<!-- /YOUTUBE -->
|
|
||||||
|
|
||||||
<!-- UPDATE_LOG -->
|
|
||||||
|
|
||||||
- Added predefined host tags that admins can set, with suggestions in the host editor
|
|
||||||
- Kept compact host row actions on the same line as the host name
|
|
||||||
- Added a reload button when a sign-in verification method fails to load
|
|
||||||
- Admins can now merge a duplicate SSO account made by 2.9.0 back into the original account
|
|
||||||
- Hosts behind the same jump hosts now share one connection for status checks
|
|
||||||
- Simplified host status to just online or offline, removing the "last login failed" state
|
|
||||||
- Host status now updates every 15 seconds without a page refresh, and status checks run every 30 seconds by default
|
|
||||||
- Completed the Simplified Chinese translation
|
|
||||||
- Documented cookie and API key sign-in in the API docs
|
|
||||||
|
|
||||||
<!-- /UPDATE_LOG -->
|
|
||||||
|
|
||||||
<!-- BUG_FIXES -->
|
|
||||||
|
|
||||||
- OIDC accounts from 2.8 not being able to sign in, or getting a new empty account, after upgrading
|
|
||||||
- Downloading a file in the file manager turning the whole screen black
|
|
||||||
- tmux sessions not resuming and new sessions being created instead
|
|
||||||
- Connections through two or more jump hosts failing
|
|
||||||
- Docker and Host Metrics failing to sign in while the terminal worked
|
|
||||||
- Fleets, Proxmox and automations not getting the host sudo password
|
|
||||||
- Server running out of memory on start and when importing hosts
|
|
||||||
- High CPU use after the app was first opened
|
|
||||||
- User data export freezing the server
|
|
||||||
- 1Password and other secret references being rejected as SSH keys
|
|
||||||
- Slow file deletes in the file manager
|
|
||||||
- Mac App Store app failing to start its local server
|
|
||||||
- Snippets failing to sync on older databases
|
|
||||||
- GitHub sign in failing
|
|
||||||
- RDP showing scrollbars and jittering near the edges
|
|
||||||
- Status checks triggering Fail2Ban bans while a session was open
|
|
||||||
- Too many database writes from session activity
|
|
||||||
- Host editor tabs being hidden when they did not fit
|
|
||||||
- SSH host keys being accepted without a check when the host was missing from the database
|
|
||||||
- Host status only updating after a full page refresh
|
|
||||||
- Every host showing offline right after signing in
|
|
||||||
- Host status flipping between online and offline on refresh or after one dropped packet
|
|
||||||
- Hosts showing "last login failed" when they connected fine
|
|
||||||
- Hosts behind a jump host staying offline after the jump connection dropped
|
|
||||||
- Host status being wiped in the desktop app when the host list failed to load
|
|
||||||
- Tunnels, remote desktop logins and other host settings missing from shared hosts in the desktop app
|
|
||||||
- SSO and LDAP provider dialogs overflowing the screen and using mismatched toggles
|
|
||||||
- Plugin audit log entries failing to save when no user was signed in
|
|
||||||
|
|
||||||
<!-- /BUG_FIXES -->
|
|
||||||
@@ -1462,6 +1462,14 @@ function createWindow() {
|
|||||||
|
|
||||||
details.requestHeaders["User-Agent"] = customUserAgent;
|
details.requestHeaders["User-Agent"] = customUserAgent;
|
||||||
|
|
||||||
|
// YouTube refuses embeds without a Referer, which file:// never sends.
|
||||||
|
if (
|
||||||
|
details.url.startsWith("https://www.youtube-nocookie.com/embed/") &&
|
||||||
|
!details.requestHeaders.Referer
|
||||||
|
) {
|
||||||
|
details.requestHeaders.Referer = "https://termix.site/";
|
||||||
|
}
|
||||||
|
|
||||||
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
|
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
|
||||||
if (rememberedJwt) {
|
if (rememberedJwt) {
|
||||||
setCookieHeaderValue(
|
setCookieHeaderValue(
|
||||||
|
|||||||
Generated
+1
-1
@@ -14955,7 +14955,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/node": "^4.2.4",
|
"@tailwindcss/node": "^4.2.4",
|
||||||
|
|||||||
+1
-1
@@ -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",
|
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs",
|
||||||
"lint: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",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ npx termix-plugin test # run the plugin's Vitest suite
|
|||||||
npx termix-plugin validate # check manifest.json and the files it names
|
npx termix-plugin validate # check manifest.json and the files it names
|
||||||
npx termix-plugin migrations # write migrations from the table definitions
|
npx termix-plugin migrations # write migrations from the table definitions
|
||||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||||
|
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
||||||
npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
|
npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
|
||||||
npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
|
npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
|
||||||
npx termix-plugin keygen --out <dir> # make a new signing key pair
|
npx termix-plugin keygen --out <dir> # make a new signing key pair
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||||
|
import { loadSdkModule } from "./validate.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints one version's notes from CHANGELOG.md, the manifest version unless
|
||||||
|
* --version says otherwise. The release workflow uses it as the release body.
|
||||||
|
*/
|
||||||
|
export async function changelog({ cwd, args = [] }) {
|
||||||
|
const index = args.indexOf("--version");
|
||||||
|
const version =
|
||||||
|
index === -1 ? readManifest(cwd).version : args[index + 1] || null;
|
||||||
|
if (!version) throw new Error("--version needs a value");
|
||||||
|
|
||||||
|
const file = path.join(cwd, "CHANGELOG.md");
|
||||||
|
if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md");
|
||||||
|
|
||||||
|
const { changelogSection } = await loadSdkModule("changelog");
|
||||||
|
const section = changelogSection(fs.readFileSync(file, "utf8"), version);
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`CHANGELOG.md has no notes for ${version}`);
|
||||||
|
}
|
||||||
|
process.stdout.write(`${section}\n`);
|
||||||
|
}
|
||||||
@@ -2,24 +2,22 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||||
import { validateChangelog } from "../lib/changelog.mjs";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||||
* there is one implementation rather than a copy here that drifts.
|
* there is one implementation rather than a copy here that drifts.
|
||||||
*/
|
*/
|
||||||
async function loadParseManifest() {
|
export async function loadSdkModule(name) {
|
||||||
const entry = new URL("../../dist/manifest.js", import.meta.url);
|
const entry = new URL(`../../dist/${name}.js`, import.meta.url);
|
||||||
if (!fs.existsSync(fileURLToPath(entry))) {
|
if (!fs.existsSync(fileURLToPath(entry))) {
|
||||||
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
||||||
}
|
}
|
||||||
const mod = await import(entry.href);
|
return import(entry.href);
|
||||||
return mod.parseManifest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validate({ cwd }) {
|
export async function validate({ cwd }) {
|
||||||
const raw = readManifest(cwd);
|
const raw = readManifest(cwd);
|
||||||
const parseManifest = await loadParseManifest();
|
const { parseManifest } = await loadSdkModule("manifest");
|
||||||
const { errors } = parseManifest(raw);
|
const { errors } = parseManifest(raw);
|
||||||
|
|
||||||
const problems = [...errors];
|
const problems = [...errors];
|
||||||
@@ -41,7 +39,7 @@ export async function validate({ cwd }) {
|
|||||||
);
|
);
|
||||||
problems.push(...validateNativeDependencies(cwd, raw));
|
problems.push(...validateNativeDependencies(cwd, raw));
|
||||||
problems.push(...validatePackage(cwd, raw));
|
problems.push(...validatePackage(cwd, raw));
|
||||||
problems.push(...validateChangelogFile(cwd, raw));
|
problems.push(...(await validateChangelogFile(cwd, raw)));
|
||||||
|
|
||||||
if (problems.length > 0) {
|
if (problems.length > 0) {
|
||||||
for (const problem of problems) console.error(` ${problem}`);
|
for (const problem of problems) console.error(` ${problem}`);
|
||||||
@@ -82,26 +80,20 @@ function validatePackage(cwd, raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CHANGELOG.json is optional, but when it is there it has to parse and its
|
* CHANGELOG.md is optional, but when it is there it has to parse and its
|
||||||
* newest release has to match the manifest version.
|
* newest release has to match the manifest version.
|
||||||
*/
|
*/
|
||||||
function validateChangelogFile(cwd, raw) {
|
async function validateChangelogFile(cwd, raw) {
|
||||||
const jsonPath = path.join(cwd, "CHANGELOG.json");
|
if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
|
||||||
if (!fs.existsSync(jsonPath)) {
|
return [
|
||||||
return fs.existsSync(path.join(cwd, "CHANGELOG.md"))
|
"CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
|
||||||
? [
|
];
|
||||||
"CHANGELOG.md is no longer shipped; move the release notes to CHANGELOG.json",
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
let changelog;
|
const mdPath = path.join(cwd, "CHANGELOG.md");
|
||||||
try {
|
if (!fs.existsSync(mdPath)) return [];
|
||||||
changelog = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
const { validateChangelog } = await loadSdkModule("changelog");
|
||||||
} catch (error) {
|
return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
|
||||||
return [`CHANGELOG.json is not valid JSON: ${error.message}`];
|
(problem) => `CHANGELOG.md: ${problem}`,
|
||||||
}
|
|
||||||
return validateChangelog(changelog, raw.version).map(
|
|
||||||
(problem) => `CHANGELOG.json: ${problem}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { pack } from "./commands/pack.mjs";
|
|||||||
import { sign, verify, keygen } from "./commands/sign.mjs";
|
import { sign, verify, keygen } from "./commands/sign.mjs";
|
||||||
import { migrations } from "./commands/migrations.mjs";
|
import { migrations } from "./commands/migrations.mjs";
|
||||||
import { dev } from "./commands/dev.mjs";
|
import { dev } from "./commands/dev.mjs";
|
||||||
|
import { changelog } from "./commands/changelog.mjs";
|
||||||
|
|
||||||
const COMMANDS = {
|
const COMMANDS = {
|
||||||
build,
|
build,
|
||||||
@@ -30,6 +31,7 @@ const COMMANDS = {
|
|||||||
keygen,
|
keygen,
|
||||||
migrations,
|
migrations,
|
||||||
dev,
|
dev,
|
||||||
|
changelog,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [command, ...args] = process.argv.slice(2);
|
const [command, ...args] = process.argv.slice(2);
|
||||||
@@ -48,6 +50,7 @@ if (!command || command === "--help" || command === "-h") {
|
|||||||
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
||||||
" keygen Write a new signing key pair [--out dir]",
|
" keygen Write a new signing key pair [--out dir]",
|
||||||
" migrations Generate migrations from the plugin's table definitions",
|
" migrations Generate migrations from the plugin's table definitions",
|
||||||
|
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
|
||||||
" dev Build, pack and install on a running server, again on every change",
|
" dev Build, pack and install on a running server, again on every change",
|
||||||
" [--server url] [--key tmx_...] [--once]",
|
" [--server url] [--key tmx_...] [--once]",
|
||||||
" (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
|
" (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
export const CHANGE_TYPES = [
|
|
||||||
"added",
|
|
||||||
"changed",
|
|
||||||
"fixed",
|
|
||||||
"removed",
|
|
||||||
"deprecated",
|
|
||||||
"security",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
||||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
const TOP_KEYS = ["$schema", "unreleased", "releases"];
|
|
||||||
const RELEASE_KEYS = ["version", "date", "notes", "changes"];
|
|
||||||
const CHANGE_KEYS = ["type", "text"];
|
|
||||||
|
|
||||||
function compareVersions(a, b) {
|
|
||||||
const pa = SEMVER.exec(a);
|
|
||||||
const pb = SEMVER.exec(b);
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
const diff = Number(pa[i]) - Number(pb[i]);
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
}
|
|
||||||
if (pa[4] === pb[4]) return 0;
|
|
||||||
if (!pa[4]) return 1;
|
|
||||||
if (!pb[4]) return -1;
|
|
||||||
return pa[4] < pb[4] ? -1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unknownKeys(value, allowed, where, problems) {
|
|
||||||
for (const key of Object.keys(value)) {
|
|
||||||
if (!allowed.includes(key))
|
|
||||||
problems.push(`${where} has unknown key "${key}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkChanges(changes, where, problems) {
|
|
||||||
if (!Array.isArray(changes)) {
|
|
||||||
problems.push(`${where} must be an array`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
changes.forEach((change, i) => {
|
|
||||||
const at = `${where}[${i}]`;
|
|
||||||
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
|
||||||
problems.push(`${at} must be an object`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unknownKeys(change, CHANGE_KEYS, at, problems);
|
|
||||||
if (!CHANGE_TYPES.includes(change.type)) {
|
|
||||||
problems.push(`${at}.type must be one of ${CHANGE_TYPES.join(", ")}`);
|
|
||||||
}
|
|
||||||
if (typeof change.text !== "string" || change.text.trim() === "") {
|
|
||||||
problems.push(`${at}.text must be a non-empty string`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks a parsed CHANGELOG.json. The newest release has to be the version
|
|
||||||
* in the manifest, so a release never ships without its notes.
|
|
||||||
*/
|
|
||||||
export function validateChangelog(changelog, manifestVersion) {
|
|
||||||
const problems = [];
|
|
||||||
if (!changelog || typeof changelog !== "object" || Array.isArray(changelog)) {
|
|
||||||
return ["CHANGELOG.json must be an object"];
|
|
||||||
}
|
|
||||||
unknownKeys(changelog, TOP_KEYS, "CHANGELOG.json", problems);
|
|
||||||
|
|
||||||
if (changelog.unreleased !== undefined) {
|
|
||||||
checkChanges(changelog.unreleased, "unreleased", problems);
|
|
||||||
}
|
|
||||||
|
|
||||||
const releases = changelog.releases;
|
|
||||||
if (!Array.isArray(releases) || releases.length === 0) {
|
|
||||||
problems.push("releases must be a non-empty array");
|
|
||||||
return problems;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
releases.forEach((release, i) => {
|
|
||||||
const at = `releases[${i}]`;
|
|
||||||
if (!release || typeof release !== "object" || Array.isArray(release)) {
|
|
||||||
problems.push(`${at} must be an object`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unknownKeys(release, RELEASE_KEYS, at, problems);
|
|
||||||
if (typeof release.version !== "string" || !SEMVER.test(release.version)) {
|
|
||||||
problems.push(`${at}.version must be a version like 1.2.3`);
|
|
||||||
} else if (seen.has(release.version)) {
|
|
||||||
problems.push(`${at}.version ${release.version} is listed twice`);
|
|
||||||
} else {
|
|
||||||
seen.add(release.version);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
release.date !== undefined &&
|
|
||||||
(typeof release.date !== "string" || !DATE.test(release.date))
|
|
||||||
) {
|
|
||||||
problems.push(`${at}.date must be a date like 2026-10-01`);
|
|
||||||
}
|
|
||||||
if (release.notes !== undefined && typeof release.notes !== "string") {
|
|
||||||
problems.push(`${at}.notes must be a string`);
|
|
||||||
}
|
|
||||||
checkChanges(release.changes, `${at}.changes`, problems);
|
|
||||||
if (Array.isArray(release.changes) && release.changes.length === 0) {
|
|
||||||
problems.push(`${at}.changes must list at least one change`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const versions = releases
|
|
||||||
.map((release) => release?.version)
|
|
||||||
.filter((v) => typeof v === "string" && SEMVER.test(v));
|
|
||||||
for (let i = 1; i < versions.length; i++) {
|
|
||||||
if (compareVersions(versions[i - 1], versions[i]) < 0) {
|
|
||||||
problems.push("releases must be listed newest first");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newest = releases[0]?.version;
|
|
||||||
if (manifestVersion && newest !== manifestVersion) {
|
|
||||||
problems.push(
|
|
||||||
`the newest release is ${newest}, but manifest version is ${manifestVersion}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return problems;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [
|
|||||||
"locales",
|
"locales",
|
||||||
"migrations",
|
"migrations",
|
||||||
"README.md",
|
"README.md",
|
||||||
"CHANGELOG.json",
|
"CHANGELOG.md",
|
||||||
"icon.svg",
|
"icon.svg",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"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": {
|
||||||
@@ -49,6 +49,10 @@
|
|||||||
"types": "./dist/capabilities.d.ts",
|
"types": "./dist/capabilities.d.ts",
|
||||||
"default": "./dist/capabilities.js"
|
"default": "./dist/capabilities.js"
|
||||||
},
|
},
|
||||||
|
"./changelog": {
|
||||||
|
"types": "./dist/changelog.d.ts",
|
||||||
|
"default": "./dist/changelog.js"
|
||||||
|
},
|
||||||
"./settings": {
|
"./settings": {
|
||||||
"types": "./dist/settings.d.ts",
|
"types": "./dist/settings.d.ts",
|
||||||
"default": "./dist/settings.js"
|
"default": "./dist/settings.js"
|
||||||
@@ -75,7 +79,6 @@
|
|||||||
},
|
},
|
||||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||||
"./schema/changelog.schema.json": "./schema/changelog.schema.json",
|
|
||||||
"./ui": {
|
"./ui": {
|
||||||
"types": "./dist/host/ui.d.ts",
|
"types": "./dist/host/ui.d.ts",
|
||||||
"default": "./dist/host/ui.js"
|
"default": "./dist/host/ui.js"
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://raw.githubusercontent.com/Termix-SSH/Termix/main/packages/plugin-sdk/schema/changelog.schema.json",
|
|
||||||
"title": "Termix plugin changelog",
|
|
||||||
"description": "Every release of a plugin, newest first. The newest release must match the manifest version.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["releases"],
|
|
||||||
"properties": {
|
|
||||||
"$schema": { "type": "string" },
|
|
||||||
"unreleased": {
|
|
||||||
"description": "Changes that are merged but not released yet.",
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/change" }
|
|
||||||
},
|
|
||||||
"releases": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": { "$ref": "#/$defs/release" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"release": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["version", "changes"],
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$"
|
|
||||||
},
|
|
||||||
"date": {
|
|
||||||
"description": "When the version was released. Leave it out until it ships.",
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"description": "A short line shown above the list of changes.",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"changes": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": { "$ref": "#/$defs/change" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"change": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["type", "text"],
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"enum": [
|
|
||||||
"added",
|
|
||||||
"changed",
|
|
||||||
"fixed",
|
|
||||||
"removed",
|
|
||||||
"deprecated",
|
|
||||||
"security"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"text": { "type": "string", "minLength": 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,6 +74,11 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "A Lucide icon name, like \"Play\"."
|
"description": "A Lucide icon name, like \"Play\"."
|
||||||
},
|
},
|
||||||
|
"video": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
||||||
|
"description": "A YouTube link shown at the top of the plugin's page."
|
||||||
|
},
|
||||||
"engine": {
|
"engine": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
|
||||||
|
*
|
||||||
|
* # Changelog
|
||||||
|
*
|
||||||
|
* ## Unreleased
|
||||||
|
*
|
||||||
|
* ### Added
|
||||||
|
* - Something merged but not shipped yet
|
||||||
|
*
|
||||||
|
* ## 1.1.0 - 2026-10-06
|
||||||
|
*
|
||||||
|
* An optional summary paragraph.
|
||||||
|
*
|
||||||
|
* ### Added
|
||||||
|
* - Something new
|
||||||
|
*
|
||||||
|
* ### Fixed
|
||||||
|
* - Something broken
|
||||||
|
*
|
||||||
|
* Releases are listed newest first, and the newest one has to be the
|
||||||
|
* manifest version. The date is optional until the version ships.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
export const CHANGE_TYPES = [
|
||||||
|
"added",
|
||||||
|
"changed",
|
||||||
|
"fixed",
|
||||||
|
"removed",
|
||||||
|
"deprecated",
|
||||||
|
"security",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ChangeType = (typeof CHANGE_TYPES)[number];
|
||||||
|
|
||||||
|
export interface ChangelogChange {
|
||||||
|
type: ChangeType;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangelogRelease {
|
||||||
|
version: string;
|
||||||
|
date?: string;
|
||||||
|
summary?: string;
|
||||||
|
changes: ChangelogChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changelog {
|
||||||
|
unreleased: ChangelogChange[];
|
||||||
|
/** Newest first, as written. */
|
||||||
|
releases: ChangelogRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseNotes {
|
||||||
|
summary?: string;
|
||||||
|
changes: ChangelogChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELEASE_HEADING =
|
||||||
|
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
|
||||||
|
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
|
||||||
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const BULLET = /^[-*]\s+(.*)$/;
|
||||||
|
const COMMENT = /^<!--.*-->$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the body under one release heading: an optional summary, then
|
||||||
|
* `### Added` style sections of bullets. Indented lines continue the bullet
|
||||||
|
* above them.
|
||||||
|
*/
|
||||||
|
export function parseReleaseNotes(
|
||||||
|
body: string,
|
||||||
|
where = "release",
|
||||||
|
): ReleaseNotes & { problems: string[] } {
|
||||||
|
const problems: string[] = [];
|
||||||
|
const summary: string[] = [];
|
||||||
|
const changes: ChangelogChange[] = [];
|
||||||
|
let section: ChangeType | null = null;
|
||||||
|
let current: ChangelogChange | null = null;
|
||||||
|
let fence = false;
|
||||||
|
|
||||||
|
for (const raw of body.replace(/\r/g, "").split("\n")) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("```")) fence = !fence;
|
||||||
|
|
||||||
|
if (!fence && trimmed.startsWith("### ")) {
|
||||||
|
const name = trimmed.slice(4).trim().toLowerCase();
|
||||||
|
if ((CHANGE_TYPES as readonly string[]).includes(name)) {
|
||||||
|
section = name as ChangeType;
|
||||||
|
} else {
|
||||||
|
problems.push(
|
||||||
|
`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`,
|
||||||
|
);
|
||||||
|
section = null;
|
||||||
|
}
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
if (trimmed || summary.length > 0) summary.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed || COMMENT.test(trimmed)) continue;
|
||||||
|
const bullet = fence ? null : BULLET.exec(line);
|
||||||
|
if (bullet) {
|
||||||
|
current = { type: section, text: bullet[1].trim() };
|
||||||
|
changes.push(current);
|
||||||
|
} else if (current && /^\s/.test(line)) {
|
||||||
|
current.text += `\n${trimmed}`;
|
||||||
|
} else {
|
||||||
|
problems.push(
|
||||||
|
`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!change.text) {
|
||||||
|
problems.push(`${where} has an empty list item`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = summary.join("\n").trim();
|
||||||
|
return { ...(text ? { summary: text } : {}), changes, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
|
||||||
|
export function parseChangelog(markdown: string): {
|
||||||
|
changelog: Changelog;
|
||||||
|
problems: string[];
|
||||||
|
} {
|
||||||
|
const problems: string[] = [];
|
||||||
|
const changelog: Changelog = { unreleased: [], releases: [] };
|
||||||
|
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||||
|
|
||||||
|
type Block = { heading: string; line: number; body: string[] };
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
let fence = false;
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (line.trim().startsWith("```")) fence = !fence;
|
||||||
|
if (!fence && /^##\s/.test(line)) {
|
||||||
|
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
|
||||||
|
} else if (blocks.length > 0) {
|
||||||
|
blocks[blocks.length - 1].body.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
blocks.forEach((block, i) => {
|
||||||
|
if (UNRELEASED_HEADING.test(block.heading)) {
|
||||||
|
if (i !== 0) problems.push(`"## Unreleased" has to come first`);
|
||||||
|
const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased");
|
||||||
|
problems.push(...notes.problems);
|
||||||
|
changelog.unreleased = notes.changes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = RELEASE_HEADING.exec(block.heading);
|
||||||
|
if (!match) {
|
||||||
|
problems.push(
|
||||||
|
`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [, version, date] = match;
|
||||||
|
if (date !== undefined && !DATE.test(date)) {
|
||||||
|
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
|
||||||
|
}
|
||||||
|
if (seen.has(version)) problems.push(`${version} is listed twice`);
|
||||||
|
seen.add(version);
|
||||||
|
|
||||||
|
const notes = parseReleaseNotes(block.body.join("\n"), version);
|
||||||
|
problems.push(...notes.problems);
|
||||||
|
if (notes.changes.length === 0) {
|
||||||
|
problems.push(`${version} has no changes listed`);
|
||||||
|
}
|
||||||
|
changelog.releases.push({
|
||||||
|
version,
|
||||||
|
...(date && DATE.test(date) ? { date } : {}),
|
||||||
|
...(notes.summary ? { summary: notes.summary } : {}),
|
||||||
|
changes: notes.changes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const versions = changelog.releases.map((r) => r.version);
|
||||||
|
for (let i = 1; i < versions.length; i++) {
|
||||||
|
if (semver.lt(versions[i - 1], versions[i])) {
|
||||||
|
problems.push("releases must be listed newest first");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changelog, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
||||||
|
* be that version, so a release never ships without its notes.
|
||||||
|
*/
|
||||||
|
export function validateChangelog(
|
||||||
|
markdown: string,
|
||||||
|
manifestVersion?: string,
|
||||||
|
): string[] {
|
||||||
|
const { changelog, problems } = parseChangelog(markdown);
|
||||||
|
if (changelog.releases.length === 0) {
|
||||||
|
problems.push('no releases found, add a "## 1.0.0" heading');
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
const newest = changelog.releases[0].version;
|
||||||
|
if (manifestVersion && newest !== manifestVersion) {
|
||||||
|
problems.push(
|
||||||
|
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Markdown under one version's heading, as written, for a release body.
|
||||||
|
* Null when the version is not in the file.
|
||||||
|
*/
|
||||||
|
export function changelogSection(
|
||||||
|
markdown: string,
|
||||||
|
version: string,
|
||||||
|
): string | null {
|
||||||
|
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||||
|
let start = -1;
|
||||||
|
let fence = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.trim().startsWith("```")) fence = !fence;
|
||||||
|
if (fence || !/^##\s/.test(line)) continue;
|
||||||
|
if (start >= 0) {
|
||||||
|
return lines.slice(start, i).join("\n").trim();
|
||||||
|
}
|
||||||
|
const match = RELEASE_HEADING.exec(line.trim());
|
||||||
|
if (match && match[1] === version) start = i + 1;
|
||||||
|
}
|
||||||
|
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(type: string): string {
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
}
|
||||||
@@ -49,6 +49,43 @@ export function isTermixCompatible(
|
|||||||
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||||
|
const YOUTUBE_HOSTS = new Set([
|
||||||
|
"youtube.com",
|
||||||
|
"www.youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"youtube-nocookie.com",
|
||||||
|
"www.youtube-nocookie.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The video id of a YouTube link, or null for anything else. Only the id is
|
||||||
|
* ever used, so a manifest can never point the embed at another site.
|
||||||
|
*/
|
||||||
|
export function youtubeVideoId(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password) return null;
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
if (!YOUTUBE_HOSTS.has(host)) return null;
|
||||||
|
|
||||||
|
let id: string | null | undefined;
|
||||||
|
if (host === "youtu.be") {
|
||||||
|
id = url.pathname.slice(1);
|
||||||
|
} else if (url.pathname === "/watch") {
|
||||||
|
id = url.searchParams.get("v");
|
||||||
|
} else {
|
||||||
|
id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1];
|
||||||
|
}
|
||||||
|
return id && YOUTUBE_ID.test(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
export const PLUGIN_CATEGORIES = [
|
export const PLUGIN_CATEGORIES = [
|
||||||
"Terminal",
|
"Terminal",
|
||||||
"Files & Transfer",
|
"Files & Transfer",
|
||||||
@@ -540,6 +577,8 @@ export interface PluginManifest {
|
|||||||
repository?: string;
|
repository?: string;
|
||||||
category: string;
|
category: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
/** A YouTube link shown at the top of the plugin's page. */
|
||||||
|
video?: string;
|
||||||
engine: PluginEngine;
|
engine: PluginEngine;
|
||||||
/** Catalog capability ids. */
|
/** Catalog capability ids. */
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
@@ -591,6 +630,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|||||||
...REQUIRED_TOP_LEVEL,
|
...REQUIRED_TOP_LEVEL,
|
||||||
"repository",
|
"repository",
|
||||||
"icon",
|
"icon",
|
||||||
|
"video",
|
||||||
"dependencies",
|
"dependencies",
|
||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"provides",
|
"provides",
|
||||||
@@ -747,6 +787,11 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
|
|
||||||
if ("repository" in m) requireString(m.repository, "repository", errors);
|
if ("repository" in m) requireString(m.repository, "repository", errors);
|
||||||
if ("icon" in m) requireString(m.icon, "icon", errors);
|
if ("icon" in m) requireString(m.icon, "icon", errors);
|
||||||
|
if ("video" in m && youtubeVideoId(m.video) === null) {
|
||||||
|
errors.push(
|
||||||
|
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||||
if (field in m && requireString(m[field], field, errors)) {
|
if (field in m && requireString(m[field], field, errors)) {
|
||||||
requireRelativePath(m[field] as string, field, errors);
|
requireRelativePath(m[field] as string, field, errors);
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage
|
url: https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_x64_appimage.AppImage
|
||||||
sha256: CHECKSUM_X64_PLACEHOLDER
|
sha256: CHECKSUM_X64_PLACEHOLDER
|
||||||
dest-filename: termix.AppImage
|
dest-filename: termix.AppImage
|
||||||
only-arches:
|
only-arches:
|
||||||
- x86_64
|
- x86_64
|
||||||
|
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage
|
url: https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_arm64_appimage.AppImage
|
||||||
sha256: CHECKSUM_ARM64_PLACEHOLDER
|
sha256: CHECKSUM_ARM64_PLACEHOLDER
|
||||||
dest-filename: termix.AppImage
|
dest-filename: termix.AppImage
|
||||||
only-arches:
|
only-arches:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const sdk = path.join(root, "packages/plugin-sdk/dist/changelog.js");
|
||||||
|
|
||||||
|
if (!fs.existsSync(sdk)) {
|
||||||
|
console.error("check-changelog: build the SDK first (npm run build:sdk)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parseChangelog } = require(sdk);
|
||||||
|
const { problems } = parseChangelog(
|
||||||
|
fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"),
|
||||||
|
);
|
||||||
|
if (problems.length > 0) {
|
||||||
|
for (const problem of problems) console.error(` CHANGELOG.md: ${problem}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { changelogSection } = require("./lib/changelog-section.cjs");
|
||||||
|
|
||||||
// Apple caps the "What's New in This Version" field at 4000 characters.
|
// Apple caps the "What's New in This Version" field at 4000 characters.
|
||||||
const MAX_LENGTH = 4000;
|
const MAX_LENGTH = 4000;
|
||||||
@@ -26,18 +27,6 @@ function fail(message) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSection(notes, name, { required = true } = {}) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
|
|
||||||
);
|
|
||||||
const match = notes.match(pattern);
|
|
||||||
if (!match) {
|
|
||||||
if (required) fail(`missing <!-- ${name} --> section in release notes`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip markdown that reads badly as plain text in App Store Connect.
|
// Strip markdown that reads badly as plain text in App Store Connect.
|
||||||
function toPlainText(markdown) {
|
function toPlainText(markdown) {
|
||||||
return markdown
|
return markdown
|
||||||
@@ -50,7 +39,7 @@ function toPlainText(markdown) {
|
|||||||
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||||
text = text.replace(/`([^`]+)`/g, "$1");
|
text = text.replace(/`([^`]+)`/g, "$1");
|
||||||
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
||||||
text = text.replace(/^#+\s*/, "");
|
if (/^#+\s/.test(text)) text = `${text.replace(/^#+\s*/, "")}:`;
|
||||||
return text;
|
return text;
|
||||||
})
|
})
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -78,28 +67,21 @@ function truncate(text, limit) {
|
|||||||
return lines.join("\n").trim();
|
return lines.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNotes(notesFile) {
|
function buildNotes(changelog, version) {
|
||||||
const summary = extractSection(notesFile, "SUMMARY");
|
const notes = changelogSection(changelog, version);
|
||||||
const updateLog = extractSection(notesFile, "UPDATE_LOG", {
|
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||||
required: false,
|
const body = toPlainText(notes);
|
||||||
});
|
|
||||||
const bugFixes = extractSection(notesFile, "BUG_FIXES", { required: false });
|
|
||||||
|
|
||||||
const parts = [toPlainText(summary)];
|
|
||||||
if (updateLog) parts.push("", "Update Log:", toPlainText(updateLog));
|
|
||||||
if (bugFixes) parts.push("", "Bug Fixes:", toPlainText(bugFixes));
|
|
||||||
|
|
||||||
const body = parts
|
|
||||||
.join("\n")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim();
|
|
||||||
if (!body) fail("release notes produced no text");
|
if (!body) fail("release notes produced no text");
|
||||||
return truncate(body, MAX_LENGTH);
|
return truncate(body, MAX_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const notesPath = args.notes || "RELEASE_NOTES.md";
|
const changelogPath = args.changelog || "CHANGELOG.md";
|
||||||
|
const version =
|
||||||
|
typeof args.version === "string"
|
||||||
|
? args.version
|
||||||
|
: require(path.resolve("package.json")).version;
|
||||||
const outDir = args["out-dir"];
|
const outDir = args["out-dir"];
|
||||||
const locales = String(args.locales || "en-US")
|
const locales = String(args.locales || "en-US")
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -109,12 +91,10 @@ function main() {
|
|||||||
if (!outDir || outDir === true) fail("--out-dir is required");
|
if (!outDir || outDir === true) fail("--out-dir is required");
|
||||||
if (locales.length === 0) fail("--locales resolved to no locales");
|
if (locales.length === 0) fail("--locales resolved to no locales");
|
||||||
|
|
||||||
const resolvedNotes = path.resolve(notesPath);
|
const resolved = path.resolve(changelogPath);
|
||||||
if (!fs.existsSync(resolvedNotes)) {
|
if (!fs.existsSync(resolved)) fail(`changelog not found: ${resolved}`);
|
||||||
fail(`release notes file not found: ${resolvedNotes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notes = buildNotes(fs.readFileSync(resolvedNotes, "utf8"));
|
const notes = buildNotes(fs.readFileSync(resolved, "utf8"), version);
|
||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
const localeDir = path.join(path.resolve(outDir), locale);
|
const localeDir = path.join(path.resolve(outDir), locale);
|
||||||
|
|||||||
@@ -9,10 +9,20 @@ const {
|
|||||||
MAX_LENGTH,
|
MAX_LENGTH,
|
||||||
} = require("./generate-appstore-notes.cjs");
|
} = require("./generate-appstore-notes.cjs");
|
||||||
|
|
||||||
function notesFile(sections: Record<string, string>) {
|
function changelog(body: string, version = "26.10.0") {
|
||||||
return Object.entries(sections)
|
return [
|
||||||
.map(([name, body]) => `<!-- ${name} -->\n${body}\n<!-- /${name} -->`)
|
"# Changelog",
|
||||||
.join("\n\n");
|
"",
|
||||||
|
`## ${version}`,
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
"",
|
||||||
|
"## 26.9.0",
|
||||||
|
"",
|
||||||
|
"### Added",
|
||||||
|
"- Older",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("toPlainText", () => {
|
describe("toPlainText", () => {
|
||||||
@@ -36,6 +46,10 @@ describe("toPlainText", () => {
|
|||||||
expect(toPlainText("* One\n* Two")).toBe("- One\n- Two");
|
expect(toPlainText("* One\n* Two")).toBe("- One\n- Two");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("turns headings into labels", () => {
|
||||||
|
expect(toPlainText("### Added\n- One")).toBe("Added:\n- One");
|
||||||
|
});
|
||||||
|
|
||||||
it("collapses runs of blank lines", () => {
|
it("collapses runs of blank lines", () => {
|
||||||
expect(toPlainText("- One\n\n\n\n- Two")).toBe("- One\n\n- Two");
|
expect(toPlainText("- One\n\n\n\n- Two")).toBe("- One\n\n- Two");
|
||||||
});
|
});
|
||||||
@@ -52,74 +66,40 @@ describe("truncate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops a section header left with no bullets under it", () => {
|
it("drops a section header left with no bullets under it", () => {
|
||||||
const result = truncate("- One\n\nBug Fixes:\n- Two", 18);
|
const result = truncate("- One\n\nFixed:\n- Two", 14);
|
||||||
expect(result).toBe("- One");
|
expect(result).toBe("- One");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildNotes", () => {
|
describe("buildNotes", () => {
|
||||||
it("includes the summary, update log, and bug fixes", () => {
|
it("uses only the version's section", () => {
|
||||||
const notes = buildNotes(
|
const notes = buildNotes(
|
||||||
notesFile({
|
changelog("A summary.\n\n### Added\n- A thing\n\n### Fixed\n- A bug"),
|
||||||
SUMMARY: "A big release.",
|
"26.10.0",
|
||||||
UPDATE_LOG: "- Added a thing",
|
|
||||||
BUG_FIXES: "- Fixed a thing",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(notes).toBe("A summary.\n\nAdded:\n- A thing\n\nFixed:\n- A bug");
|
||||||
expect(notes).toContain("A big release.");
|
expect(notes).not.toContain("Older");
|
||||||
expect(notes).toContain("Update Log:");
|
|
||||||
expect(notes).toContain("- Added a thing");
|
|
||||||
expect(notes).toContain("Bug Fixes:");
|
|
||||||
expect(notes).toContain("- Fixed a thing");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits optional sections that are absent", () => {
|
it("stays within the App Store limit and never ends mid-bullet", () => {
|
||||||
const notes = buildNotes(notesFile({ SUMMARY: "Small release." }));
|
const bullets = Array.from(
|
||||||
|
{ length: 400 },
|
||||||
expect(notes).toBe("Small release.");
|
(_, i) => `- Added feature number ${i}`,
|
||||||
expect(notes).not.toContain("Update Log:");
|
);
|
||||||
expect(notes).not.toContain("Bug Fixes:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays within the App Store character limit", () => {
|
|
||||||
const notes = buildNotes(
|
const notes = buildNotes(
|
||||||
notesFile({
|
changelog(`### Added\n${bullets.join("\n")}`),
|
||||||
SUMMARY: "Big release.",
|
"26.10.0",
|
||||||
UPDATE_LOG: Array.from(
|
|
||||||
{ length: 400 },
|
|
||||||
(_, i) => `- Added feature number ${i}`,
|
|
||||||
).join("\n"),
|
|
||||||
BUG_FIXES: Array.from(
|
|
||||||
{ length: 400 },
|
|
||||||
(_, i) => `- Fixed bug number ${i}`,
|
|
||||||
).join("\n"),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
||||||
expect(notes.length).toBeGreaterThan(0);
|
expect(notes.split("\n").at(-1)).toMatch(/^- Added feature number \d+$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never ends mid-bullet when truncating", () => {
|
it("produces notes for the real changelog", () => {
|
||||||
const notes = buildNotes(
|
|
||||||
notesFile({
|
|
||||||
SUMMARY: "Big release.",
|
|
||||||
UPDATE_LOG: Array.from(
|
|
||||||
{ length: 400 },
|
|
||||||
(_, i) => `- Added feature number ${i}`,
|
|
||||||
).join("\n"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = notes.split("\n");
|
|
||||||
expect(lines[lines.length - 1]).toMatch(/^- Added feature number \d+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces non-empty notes for the real release notes file", () => {
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const notes = buildNotes(fs.readFileSync("RELEASE_NOTES.md", "utf8"));
|
const notes = buildNotes(
|
||||||
|
fs.readFileSync("CHANGELOG.md", "utf8"),
|
||||||
|
require("../package.json").version,
|
||||||
|
);
|
||||||
expect(notes.length).toBeGreaterThan(0);
|
expect(notes.length).toBeGreaterThan(0);
|
||||||
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { changelogSection } = require("./lib/changelog-section.cjs");
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const args = {};
|
const args = {};
|
||||||
@@ -24,37 +25,10 @@ function fail(message) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSection(notes, name) {
|
function buildTable(version, mobileVersion, mobileTag) {
|
||||||
const pattern = new RegExp(
|
const tag = `v${version}`;
|
||||||
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
|
|
||||||
);
|
|
||||||
const match = notes.match(pattern);
|
|
||||||
if (!match) {
|
|
||||||
fail(`missing <!-- ${name} --> section in release notes`);
|
|
||||||
}
|
|
||||||
const value = match[1].trim();
|
|
||||||
if (!value) {
|
|
||||||
fail(`empty <!-- ${name} --> section in release notes`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function youtubeId(raw) {
|
|
||||||
const value = raw.trim();
|
|
||||||
let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
|
||||||
if (match) return match[1];
|
|
||||||
match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
|
||||||
if (match) return match[1];
|
|
||||||
match = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
|
||||||
if (match) return match[1];
|
|
||||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
|
||||||
fail(`could not parse a YouTube video id from "${value}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTable(version, mobileVersion) {
|
|
||||||
const tag = `release-${version}-tag`;
|
|
||||||
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
||||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/release-${mobileVersion}-tag`;
|
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`;
|
||||||
|
|
||||||
const win = (file) => `${base}/${file}`;
|
const win = (file) => `${base}/${file}`;
|
||||||
const linux = (file) => `${base}/${file}`;
|
const linux = (file) => `${base}/${file}`;
|
||||||
@@ -71,58 +45,49 @@ function buildTable(version, mobileVersion) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const notes = changelogSection(changelog, version);
|
||||||
const version = args.version;
|
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||||
const mobileVersion = args["mobile-version"];
|
|
||||||
const notesPath = args.notes || "RELEASE_NOTES.md";
|
|
||||||
|
|
||||||
if (!version || version === true) fail("--version is required");
|
|
||||||
if (!mobileVersion || mobileVersion === true)
|
|
||||||
fail("--mobile-version is required");
|
|
||||||
|
|
||||||
const resolvedNotes = path.resolve(notesPath);
|
|
||||||
if (!fs.existsSync(resolvedNotes)) {
|
|
||||||
fail(`release notes file not found: ${resolvedNotes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notes = fs.readFileSync(resolvedNotes, "utf8");
|
|
||||||
const summary = extractSection(notes, "SUMMARY");
|
|
||||||
const youtube = extractSection(notes, "YOUTUBE");
|
|
||||||
const updateLog = extractSection(notes, "UPDATE_LOG");
|
|
||||||
const bugFixes = extractSection(notes, "BUG_FIXES");
|
|
||||||
|
|
||||||
const videoId = youtubeId(youtube);
|
|
||||||
const embed = [
|
|
||||||
`<a href="https://youtu.be/${videoId}">`,
|
|
||||||
` <img src="./docs/repo-images/YouTube.png" alt="YouTube" width="500">`,
|
|
||||||
`</a>`,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const table = buildTable(version, mobileVersion);
|
|
||||||
|
|
||||||
const donateAlert = [
|
const donateAlert = [
|
||||||
"> [!TIP]",
|
"> [!TIP]",
|
||||||
"> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
|
"> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const body = [
|
return [
|
||||||
donateAlert,
|
donateAlert,
|
||||||
"",
|
"",
|
||||||
summary,
|
notes,
|
||||||
"",
|
"",
|
||||||
embed,
|
"### Downloads",
|
||||||
"",
|
"",
|
||||||
table,
|
buildTable(version, mobileVersion, mobileTag),
|
||||||
"",
|
|
||||||
"Update Log:",
|
|
||||||
updateLog,
|
|
||||||
"",
|
|
||||||
"Bug Fixes:",
|
|
||||||
bugFixes,
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
process.stdout.write(body + "\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const version = args.version;
|
||||||
|
const mobileVersion = args["mobile-version"];
|
||||||
|
const mobileTag =
|
||||||
|
typeof args["mobile-tag"] === "string" ? args["mobile-tag"] : undefined;
|
||||||
|
const changelogPath = path.resolve(args.changelog || "CHANGELOG.md");
|
||||||
|
|
||||||
|
if (!version || version === true) fail("--version is required");
|
||||||
|
if (!mobileVersion || mobileVersion === true)
|
||||||
|
fail("--mobile-version is required");
|
||||||
|
if (!fs.existsSync(changelogPath)) {
|
||||||
|
fail(`changelog not found: ${changelogPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelog = fs.readFileSync(changelogPath, "utf8");
|
||||||
|
process.stdout.write(
|
||||||
|
buildBody({ version, mobileVersion, mobileTag, changelog }) + "\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildBody };
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { buildBody } = require("./generate-release-body.cjs");
|
||||||
|
|
||||||
|
const CHANGELOG = [
|
||||||
|
"# Changelog",
|
||||||
|
"",
|
||||||
|
"## 26.11.0",
|
||||||
|
"",
|
||||||
|
"### Fixed",
|
||||||
|
"- A bug",
|
||||||
|
"",
|
||||||
|
"## 26.10.0",
|
||||||
|
"",
|
||||||
|
"### Added",
|
||||||
|
"- Plugins",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("buildBody", () => {
|
||||||
|
it("uses the version's changelog section and v tags for downloads", () => {
|
||||||
|
const body = buildBody({
|
||||||
|
version: "26.10.0",
|
||||||
|
mobileVersion: "1.2.0",
|
||||||
|
changelog: CHANGELOG,
|
||||||
|
});
|
||||||
|
expect(body).toContain("### Added\n- Plugins");
|
||||||
|
expect(body).not.toContain("A bug");
|
||||||
|
expect(body).toContain("/releases/download/v26.10.0/termix_");
|
||||||
|
expect(body).toContain(
|
||||||
|
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works for the real changelog", () => {
|
||||||
|
const fs = require("fs");
|
||||||
|
const body = buildBody({
|
||||||
|
version: require("../package.json").version,
|
||||||
|
mobileVersion: "1.0.0",
|
||||||
|
changelog: fs.readFileSync("CHANGELOG.md", "utf8"),
|
||||||
|
});
|
||||||
|
expect(body.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -254,7 +254,7 @@ const LOCAL_FILES = [
|
|||||||
"locales",
|
"locales",
|
||||||
"migrations",
|
"migrations",
|
||||||
"README.md",
|
"README.md",
|
||||||
"CHANGELOG.json",
|
"CHANGELOG.md",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* The Markdown under one version's "## x.y.z" heading in CHANGELOG.md.
|
||||||
|
* The release scripts run before the SDK is built, so this is a small copy
|
||||||
|
* of changelogSection from packages/plugin-sdk/src/changelog.ts; lint checks
|
||||||
|
* the whole file with the real parser.
|
||||||
|
*/
|
||||||
|
function changelogSection(markdown, version) {
|
||||||
|
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||||
|
const heading =
|
||||||
|
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+.+?)?\s*$/;
|
||||||
|
let start = -1;
|
||||||
|
let fence = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.trim().startsWith("```")) fence = !fence;
|
||||||
|
if (fence || !/^##\s/.test(line)) continue;
|
||||||
|
if (start >= 0) return lines.slice(start, i).join("\n").trim();
|
||||||
|
const match = heading.exec(line.trim());
|
||||||
|
if (match && match[1] === version) start = i + 1;
|
||||||
|
}
|
||||||
|
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { changelogSection };
|
||||||
@@ -1,113 +1,177 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { validateChangelog } from "../packages/plugin-sdk/cli/lib/changelog.mjs";
|
import {
|
||||||
|
changelogSection,
|
||||||
|
parseChangelog,
|
||||||
|
parseReleaseNotes,
|
||||||
|
validateChangelog,
|
||||||
|
} from "../packages/plugin-sdk/src/changelog.ts";
|
||||||
|
|
||||||
function release(version: string, extra: Record<string, unknown> = {}) {
|
const FILE = [
|
||||||
return {
|
"# Changelog",
|
||||||
version,
|
"",
|
||||||
date: "2026-10-01",
|
"## Unreleased",
|
||||||
changes: [{ type: "added", text: "Something new" }],
|
"",
|
||||||
...extra,
|
"### Fixed",
|
||||||
};
|
"- A bug not shipped yet",
|
||||||
}
|
"",
|
||||||
|
"## 1.1.0 - 2026-10-06",
|
||||||
|
"",
|
||||||
|
"Second release.",
|
||||||
|
"",
|
||||||
|
"### Added",
|
||||||
|
"- Something new",
|
||||||
|
" that wraps",
|
||||||
|
"* Another thing",
|
||||||
|
"",
|
||||||
|
"### Fixed",
|
||||||
|
"- Something broken",
|
||||||
|
"",
|
||||||
|
"## 1.0.0",
|
||||||
|
"",
|
||||||
|
"### Added",
|
||||||
|
"- The first version",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
describe("validateChangelog", () => {
|
describe("parseChangelog", () => {
|
||||||
it("accepts a changelog whose newest release matches the manifest", () => {
|
it("reads releases, summaries, sections and unreleased changes", () => {
|
||||||
const changelog = {
|
const { changelog, problems } = parseChangelog(FILE);
|
||||||
$schema: "https://example.com/changelog.schema.json",
|
expect(problems).toEqual([]);
|
||||||
unreleased: [{ type: "fixed", text: "A bug" }],
|
expect(changelog.unreleased).toEqual([
|
||||||
releases: [
|
{ type: "fixed", text: "A bug not shipped yet" },
|
||||||
release("1.1.0", { notes: "Second release." }),
|
]);
|
||||||
release("1.0.0"),
|
expect(changelog.releases).toEqual([
|
||||||
],
|
{
|
||||||
};
|
version: "1.1.0",
|
||||||
expect(validateChangelog(changelog, "1.1.0")).toEqual([]);
|
date: "2026-10-06",
|
||||||
|
summary: "Second release.",
|
||||||
|
changes: [
|
||||||
|
{ type: "added", text: "Something new\nthat wraps" },
|
||||||
|
{ type: "added", text: "Another thing" },
|
||||||
|
{ type: "fixed", text: "Something broken" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
changes: [{ type: "added", text: "The first version" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a release that has no date yet", () => {
|
it("accepts Keep a Changelog brackets, CRLF and calendar versions", () => {
|
||||||
const { date: _date, ...undated } = release("1.0.0");
|
const { changelog, problems } = parseChangelog(
|
||||||
expect(validateChangelog({ releases: [undated] }, "1.0.0")).toEqual([]);
|
"## [26.10.0] - 2026-10-06\r\n\r\n### Changed\r\n- A thing\r\n",
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a manifest version with no release notes", () => {
|
|
||||||
const problems = validateChangelog(
|
|
||||||
{ releases: [release("1.0.0")] },
|
|
||||||
"1.1.0",
|
|
||||||
);
|
);
|
||||||
expect(problems).toContain(
|
expect(problems).toEqual([]);
|
||||||
"the newest release is 1.0.0, but manifest version is 1.1.0",
|
expect(changelog.releases[0]).toMatchObject({
|
||||||
);
|
version: "26.10.0",
|
||||||
});
|
date: "2026-10-06",
|
||||||
|
});
|
||||||
it("requires releases newest first and without duplicates", () => {
|
|
||||||
expect(
|
|
||||||
validateChangelog(
|
|
||||||
{ releases: [release("1.0.0"), release("1.2.0")] },
|
|
||||||
"1.0.0",
|
|
||||||
),
|
|
||||||
).toContain("releases must be listed newest first");
|
|
||||||
expect(
|
|
||||||
validateChangelog(
|
|
||||||
{ releases: [release("1.0.0"), release("1.0.0")] },
|
|
||||||
"1.0.0",
|
|
||||||
),
|
|
||||||
).toContain("releases[1].version 1.0.0 is listed twice");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders a prerelease below its release", () => {
|
it("orders a prerelease below its release", () => {
|
||||||
expect(
|
const md = (a: string, b: string) =>
|
||||||
validateChangelog(
|
`## ${a}\n### Added\n- x\n## ${b}\n### Added\n- y\n`;
|
||||||
{ releases: [release("2.0.0"), release("2.0.0-beta.1")] },
|
expect(parseChangelog(md("2.0.0", "2.0.0-beta.1")).problems).toEqual([]);
|
||||||
"2.0.0",
|
expect(parseChangelog(md("2.0.0-beta.1", "2.0.0")).problems).toContain(
|
||||||
),
|
"releases must be listed newest first",
|
||||||
).toEqual([]);
|
);
|
||||||
expect(
|
|
||||||
validateChangelog(
|
|
||||||
{ releases: [release("2.0.0-beta.1"), release("2.0.0")] },
|
|
||||||
"2.0.0-beta.1",
|
|
||||||
),
|
|
||||||
).toContain("releases must be listed newest first");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects bad versions, dates, change types and empty text", () => {
|
it("flags bad headings, dates, sections, stray text and duplicates", () => {
|
||||||
const problems = validateChangelog(
|
const { problems } = parseChangelog(
|
||||||
{
|
[
|
||||||
releases: [
|
"## v1",
|
||||||
{
|
"## 1.0.0 - October 1",
|
||||||
version: "v1",
|
"### Improved",
|
||||||
date: "October 1",
|
"- x",
|
||||||
changes: [{ type: "improved", text: " " }],
|
"### Added",
|
||||||
},
|
"loose text",
|
||||||
],
|
"- y",
|
||||||
},
|
"## 1.0.0",
|
||||||
undefined,
|
"### Added",
|
||||||
|
"- z",
|
||||||
|
].join("\n"),
|
||||||
);
|
);
|
||||||
expect(problems).toEqual(
|
expect(problems).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"releases[0].version must be a version like 1.2.3",
|
'line 1: "## v1" should look like "## 1.2.0 - 2026-10-06"',
|
||||||
"releases[0].date must be a date like 2026-10-01",
|
'1.0.0 has a date "October 1", use YYYY-MM-DD',
|
||||||
expect.stringContaining("releases[0].changes[0].type must be one of"),
|
expect.stringContaining('unknown section "Improved"'),
|
||||||
"releases[0].changes[0].text must be a non-empty string",
|
expect.stringContaining("that is not a list item: loose text"),
|
||||||
|
"1.0.0 is listed twice",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown keys, empty change lists and missing releases", () => {
|
it("flags a release with no changes and Unreleased out of place", () => {
|
||||||
expect(validateChangelog({ releases: [] }, "1.0.0")).toEqual([
|
const { problems } = parseChangelog(
|
||||||
"releases must be a non-empty array",
|
"## 1.0.0\n\nJust a summary.\n\n## Unreleased\n### Added\n- x\n",
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
validateChangelog(
|
|
||||||
{ extra: true, releases: [release("1.0.0", { changes: [] })] },
|
|
||||||
"1.0.0",
|
|
||||||
),
|
|
||||||
).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
'CHANGELOG.json has unknown key "extra"',
|
|
||||||
"releases[0].changes must list at least one change",
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
expect(validateChangelog([], "1.0.0")).toEqual([
|
expect(problems).toContain("1.0.0 has no changes listed");
|
||||||
"CHANGELOG.json must be an object",
|
expect(problems).toContain('"## Unreleased" has to come first');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores headings inside code fences", () => {
|
||||||
|
const { changelog, problems } = parseChangelog(
|
||||||
|
"## 1.0.0\n\n```\n## not a heading\n```\n\n### Added\n- x\n",
|
||||||
|
);
|
||||||
|
expect(problems).toEqual([]);
|
||||||
|
expect(changelog.releases).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateChangelog", () => {
|
||||||
|
it("passes when the newest release is the manifest version", () => {
|
||||||
|
expect(validateChangelog(FILE, "1.1.0")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a manifest version with no notes", () => {
|
||||||
|
expect(validateChangelog(FILE, "1.2.0")).toContain(
|
||||||
|
"the newest release is 1.1.0, but the manifest version is 1.2.0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a file with no releases", () => {
|
||||||
|
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
|
||||||
|
'no releases found, add a "## 1.0.0" heading',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("changelogSection", () => {
|
||||||
|
it("returns the Markdown under a version's heading", () => {
|
||||||
|
expect(changelogSection(FILE, "1.1.0")).toBe(
|
||||||
|
[
|
||||||
|
"Second release.",
|
||||||
|
"",
|
||||||
|
"### Added",
|
||||||
|
"- Something new",
|
||||||
|
" that wraps",
|
||||||
|
"* Another thing",
|
||||||
|
"",
|
||||||
|
"### Fixed",
|
||||||
|
"- Something broken",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(changelogSection(FILE, "1.0.0")).toBe(
|
||||||
|
"### Added\n- The first version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for a version that is not there", () => {
|
||||||
|
expect(changelogSection(FILE, "9.9.9")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseReleaseNotes", () => {
|
||||||
|
it("parses a section body on its own", () => {
|
||||||
|
expect(
|
||||||
|
parseReleaseNotes("Summary.\n\n### Security\n- Patched a hole"),
|
||||||
|
).toEqual({
|
||||||
|
summary: "Summary.",
|
||||||
|
changes: [{ type: "security", text: "Patched a hole" }],
|
||||||
|
problems: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
function extractYoutubeId(notes) {
|
|
||||||
const match = notes.match(
|
|
||||||
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
|
|
||||||
);
|
|
||||||
if (!match || !match[1].trim()) {
|
|
||||||
throw new Error(
|
|
||||||
"missing or empty <!-- YOUTUBE --> section in release notes",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parseYoutubeId(match[1].trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseYoutubeId(raw) {
|
|
||||||
const value = raw.trim();
|
|
||||||
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
|
||||||
if (m) return m[1];
|
|
||||||
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
|
||||||
if (m) return m[1];
|
|
||||||
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
|
||||||
if (m) return m[1];
|
|
||||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
|
||||||
throw new Error(`could not parse a YouTube video id from "${value}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
|
|
||||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`token exchange failed (${res.status}): ${await res.text()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const json = await res.json();
|
|
||||||
return json.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getVideoStatus(accessToken, videoId) {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
||||||
);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
|
|
||||||
const json = await res.json();
|
|
||||||
return json.items?.[0]?.status?.privacyStatus ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setVideoPublic(accessToken, videoId) {
|
|
||||||
const status = await getVideoStatus(accessToken, videoId);
|
|
||||||
if (status === "public") {
|
|
||||||
console.log(`Video ${videoId} is already public, skipping.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch(
|
|
||||||
"https://www.googleapis.com/youtube/v3/videos?part=status",
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: videoId,
|
|
||||||
status: { privacyStatus: "public" },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`videos.update failed (${res.status}): ${await res.text()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const clientId = process.env.YOUTUBE_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
|
|
||||||
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret || !refreshToken) {
|
|
||||||
throw new Error(
|
|
||||||
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesPath = path.resolve(
|
|
||||||
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
|
|
||||||
);
|
|
||||||
const notes = fs.readFileSync(notesPath, "utf8");
|
|
||||||
const videoId = extractYoutubeId(notes);
|
|
||||||
|
|
||||||
const accessToken = await getAccessToken({
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
refreshToken,
|
|
||||||
});
|
|
||||||
await setVideoPublic(accessToken, videoId);
|
|
||||||
|
|
||||||
console.log(`Set YouTube video ${videoId} to public.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { extractYoutubeId, parseYoutubeId };
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(`publish-youtube: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createRequire } from "module";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
|
|
||||||
|
|
||||||
describe("parseYoutubeId", () => {
|
|
||||||
it("parses a youtu.be short link", () => {
|
|
||||||
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a watch?v= link", () => {
|
|
||||||
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
|
|
||||||
"At8iDk6-Q_s",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses an embed link", () => {
|
|
||||||
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
|
|
||||||
"At8iDk6-Q_s",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a bare id", () => {
|
|
||||||
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on garbage", () => {
|
|
||||||
expect(() => parseYoutubeId("https://example.com/")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractYoutubeId", () => {
|
|
||||||
it("pulls the id from a release notes YOUTUBE section", () => {
|
|
||||||
const notes = [
|
|
||||||
"<!-- SUMMARY -->",
|
|
||||||
"stuff",
|
|
||||||
"<!-- /SUMMARY -->",
|
|
||||||
"<!-- YOUTUBE -->",
|
|
||||||
"https://youtu.be/At8iDk6-Q_s",
|
|
||||||
"<!-- /YOUTUBE -->",
|
|
||||||
].join("\n");
|
|
||||||
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the YOUTUBE section is missing", () => {
|
|
||||||
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when the YOUTUBE section is empty", () => {
|
|
||||||
expect(() =>
|
|
||||||
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
|
|
||||||
).toThrow(/empty/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,7 @@ 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 { PluginManageError } from "../../plugins/manage.js";
|
import { PluginManageError } from "../../plugins/manage.js";
|
||||||
|
import { youtubeVideoId } from "../../plugins/manifest.js";
|
||||||
import {
|
import {
|
||||||
isPluginChoice,
|
isPluginChoice,
|
||||||
type PluginChoice,
|
type PluginChoice,
|
||||||
@@ -255,6 +256,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let description: string | undefined;
|
let description: string | undefined;
|
||||||
let author: string | undefined;
|
let author: string | undefined;
|
||||||
let repository: string | undefined;
|
let repository: string | undefined;
|
||||||
|
let videoId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -263,6 +265,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
description?: unknown;
|
description?: unknown;
|
||||||
author?: unknown;
|
author?: unknown;
|
||||||
repository?: unknown;
|
repository?: unknown;
|
||||||
|
video?: unknown;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
};
|
};
|
||||||
@@ -284,6 +287,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
typeof manifest?.repository === "string"
|
typeof manifest?.repository === "string"
|
||||||
? manifest.repository
|
? manifest.repository
|
||||||
: undefined;
|
: undefined;
|
||||||
|
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
||||||
} catch {
|
} catch {
|
||||||
contributes = null;
|
contributes = null;
|
||||||
}
|
}
|
||||||
@@ -313,6 +317,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
description,
|
description,
|
||||||
author,
|
author,
|
||||||
repository,
|
repository,
|
||||||
|
videoId,
|
||||||
signedBy: loaded?.signedBy ?? null,
|
signedBy: loaded?.signedBy ?? null,
|
||||||
capabilities,
|
capabilities,
|
||||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||||
@@ -1984,6 +1989,48 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/changelog:
|
||||||
|
* get:
|
||||||
|
* summary: Read an installed plugin's release notes
|
||||||
|
* description: The releases in the plugin's CHANGELOG.md, newest first, each with its date, summary and changes. Empty when the plugin ships no changelog.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The releases.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 404:
|
||||||
|
* description: No such plugin.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/changelog",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
try {
|
||||||
|
const { getPluginChangelog } = await import("../../plugins/manage.js");
|
||||||
|
res.json({ id: pluginId, releases: await getPluginChangelog(pluginId) });
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to read the plugin's release notes",
|
||||||
|
"plugin_changelog",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugins/developer-mode:
|
* /plugins/developer-mode:
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ import { readInstallCounts } from "./install-counts.js";
|
|||||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import { parseManifest, type PluginManifest } from "./manifest.js";
|
import { parseManifest, type PluginManifest } from "./manifest.js";
|
||||||
import { requireSignedPlugins } from "./trust.js";
|
import { requireSignedPlugins } from "./trust.js";
|
||||||
|
import {
|
||||||
|
parseChangelog,
|
||||||
|
parseReleaseNotes,
|
||||||
|
type ChangelogRelease,
|
||||||
|
type ReleaseNotes,
|
||||||
|
} from "@termix-ssh/plugin-sdk/changelog";
|
||||||
|
|
||||||
const UNINSTALLED_KEY = "plugins_uninstalled";
|
const UNINSTALLED_KEY = "plugins_uninstalled";
|
||||||
const DEVELOPER_MODE_KEY = "plugins_developer_mode";
|
const DEVELOPER_MODE_KEY = "plugins_developer_mode";
|
||||||
@@ -206,12 +212,14 @@ export interface RegistryListing {
|
|||||||
category: string;
|
category: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
videoId?: string;
|
||||||
versions: Array<{
|
versions: Array<{
|
||||||
version: string;
|
version: string;
|
||||||
compatible: boolean;
|
compatible: boolean;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
releaseNotesUrl?: string;
|
releaseNotesUrl?: string;
|
||||||
|
notes?: ReleaseNotes;
|
||||||
size: number;
|
size: number;
|
||||||
}>;
|
}>;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
@@ -263,12 +271,14 @@ export async function listRegistry(
|
|||||||
category: plugin.category,
|
category: plugin.category,
|
||||||
repository: plugin.repository,
|
repository: plugin.repository,
|
||||||
icon: plugin.icon,
|
icon: plugin.icon,
|
||||||
|
videoId: plugin.videoId,
|
||||||
versions: plugin.versions.map((entry) => ({
|
versions: plugin.versions.map((entry) => ({
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
compatible: isApiCompatible(entry.api),
|
compatible: isApiCompatible(entry.api),
|
||||||
capabilities: entry.capabilities,
|
capabilities: entry.capabilities,
|
||||||
publishedAt: entry.publishedAt,
|
publishedAt: entry.publishedAt,
|
||||||
releaseNotesUrl: entry.releaseNotesUrl,
|
releaseNotesUrl: entry.releaseNotesUrl,
|
||||||
|
notes: readReleaseNotes(entry.notes),
|
||||||
size: entry.size,
|
size: entry.size,
|
||||||
})),
|
})),
|
||||||
latestVersion: latest?.version ?? null,
|
latestVersion: latest?.version ?? null,
|
||||||
@@ -963,6 +973,40 @@ export function setPluginOptions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_CHANGELOG_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
/** A registry version's Markdown notes, parsed. Undefined when empty. */
|
||||||
|
export function readReleaseNotes(
|
||||||
|
markdown: string | undefined,
|
||||||
|
): ReleaseNotes | undefined {
|
||||||
|
if (!markdown) return undefined;
|
||||||
|
const { summary, changes } = parseReleaseNotes(markdown);
|
||||||
|
if (!summary && changes.length === 0) return undefined;
|
||||||
|
return { ...(summary ? { summary } : {}), changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The releases in an installed plugin's CHANGELOG.md, newest first. Empty when
|
||||||
|
* the plugin ships none or it cannot be read.
|
||||||
|
*/
|
||||||
|
export async function getPluginChangelog(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<ChangelogRelease[]> {
|
||||||
|
const plugin = getPluginRuntime().loader.get(pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
const file = path.join(plugin.dir, "CHANGELOG.md");
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(file);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_CHANGELOG_BYTES) return [];
|
||||||
|
return parseChangelog(await fs.promises.readFile(file, "utf8")).changelog
|
||||||
|
.releases;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function directorySize(dir: string): Promise<number> {
|
async function directorySize(dir: string): Promise<number> {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
SYSTEM_ROLE_NAMES,
|
SYSTEM_ROLE_NAMES,
|
||||||
RESERVED_PERMISSION_PREFIXES,
|
RESERVED_PERMISSION_PREFIXES,
|
||||||
qualifyPermission,
|
qualifyPermission,
|
||||||
|
youtubeVideoId,
|
||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest";
|
import {
|
||||||
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
|
youtubeVideoId,
|
||||||
|
} 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 { verifyPluginArtifact } from "./trust.js";
|
||||||
@@ -27,6 +30,7 @@ const CACHE_MS = 15 * 60_000;
|
|||||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||||
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
||||||
const MAX_REDIRECTS = 5;
|
const MAX_REDIRECTS = 5;
|
||||||
|
const MAX_NOTES_LENGTH = 20_000;
|
||||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
export interface RegistryVersion {
|
export interface RegistryVersion {
|
||||||
@@ -38,6 +42,8 @@ export interface RegistryVersion {
|
|||||||
size: number;
|
size: number;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
releaseNotesUrl?: string;
|
releaseNotesUrl?: string;
|
||||||
|
/** This version's CHANGELOG.md section, as Markdown. */
|
||||||
|
notes?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +55,8 @@ export interface RegistryPlugin {
|
|||||||
category: string;
|
category: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
/** Only ever a YouTube video id, never a link. */
|
||||||
|
videoId?: string;
|
||||||
/** Newest first. */
|
/** Newest first. */
|
||||||
versions: RegistryVersion[];
|
versions: RegistryVersion[];
|
||||||
}
|
}
|
||||||
@@ -95,6 +103,7 @@ function parseVersion(raw: unknown): RegistryVersion | null {
|
|||||||
? entry.capabilities.filter((c): c is string => typeof c === "string")
|
? entry.capabilities.filter((c): c is string => typeof c === "string")
|
||||||
: [],
|
: [],
|
||||||
releaseNotesUrl: asString(entry.releaseNotesUrl),
|
releaseNotesUrl: asString(entry.releaseNotesUrl),
|
||||||
|
notes: asString(entry.notes)?.slice(0, MAX_NOTES_LENGTH),
|
||||||
publishedAt: asString(entry.publishedAt),
|
publishedAt: asString(entry.publishedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -128,6 +137,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
|||||||
category: asString(entry.category) ?? "",
|
category: asString(entry.category) ?? "",
|
||||||
repository: asString(entry.repository),
|
repository: asString(entry.repository),
|
||||||
icon: asString(entry.icon),
|
icon: asString(entry.icon),
|
||||||
|
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||||
versions,
|
versions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ const manageMock = vi.hoisted(() => ({
|
|||||||
uninstallPlugin: vi.fn(),
|
uninstallPlugin: vi.fn(),
|
||||||
setPluginOptions: vi.fn(),
|
setPluginOptions: vi.fn(),
|
||||||
getPluginDataSummary: vi.fn(),
|
getPluginDataSummary: vi.fn(),
|
||||||
|
getPluginChangelog: vi.fn(),
|
||||||
deletePluginData: vi.fn(),
|
deletePluginData: vi.fn(),
|
||||||
planStateChange: vi.fn(),
|
planStateChange: vi.fn(),
|
||||||
setPluginState: vi.fn(),
|
setPluginState: vi.fn(),
|
||||||
@@ -832,6 +833,21 @@ describe("plugins route", () => {
|
|||||||
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns an installed plugin's release notes", async () => {
|
||||||
|
manageMock.getPluginChangelog.mockResolvedValue([
|
||||||
|
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||||
|
]);
|
||||||
|
const res = await fetch(`${baseUrl}/plugins/docker/changelog`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
id: "docker",
|
||||||
|
releases: [
|
||||||
|
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(manageMock.getPluginChangelog).toHaveBeenCalledWith("docker");
|
||||||
|
});
|
||||||
|
|
||||||
it("answers 502 when the registry is unreachable", async () => {
|
it("answers 502 when the registry is unreachable", async () => {
|
||||||
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
|
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
|
||||||
const res = await fetch(`${baseUrl}/plugins/registry`);
|
const res = await fetch(`${baseUrl}/plugins/registry`);
|
||||||
@@ -856,6 +872,7 @@ describe("plugins route", () => {
|
|||||||
body: JSON.stringify({ autoUpdate: true }),
|
body: JSON.stringify({ autoUpdate: true }),
|
||||||
}),
|
}),
|
||||||
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
||||||
|
fetch(`${baseUrl}/plugins/docker/changelog`, { headers }),
|
||||||
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
|
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
|
||||||
fetch(`${baseUrl}/plugins/developer-mode`, {
|
fetch(`${baseUrl}/plugins/developer-mode`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
@@ -680,3 +680,40 @@ describe("installing from a file", () => {
|
|||||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("release notes", () => {
|
||||||
|
it("reads an installed plugin's CHANGELOG.md", async () => {
|
||||||
|
createFixturePlugin({ id: "docker", root: bundled });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(bundled, "docker", "CHANGELOG.md"),
|
||||||
|
"# Changelog\n\n## 1.1.0 - 2026-10-06\n\n### Fixed\n- A bug\n\n## 1.0.0\n\n### Added\n- First\n",
|
||||||
|
);
|
||||||
|
await boot();
|
||||||
|
expect(await manage.getPluginChangelog("docker")).toEqual([
|
||||||
|
{
|
||||||
|
version: "1.1.0",
|
||||||
|
date: "2026-10-06",
|
||||||
|
changes: [{ type: "fixed", text: "A bug" }],
|
||||||
|
},
|
||||||
|
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty without a changelog and 404s for an unknown plugin", async () => {
|
||||||
|
createFixturePlugin({ id: "docker", root: bundled });
|
||||||
|
await boot();
|
||||||
|
expect(await manage.getPluginChangelog("docker")).toEqual([]);
|
||||||
|
await expect(manage.getPluginChangelog("nope")).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses registry notes and drops empty ones", () => {
|
||||||
|
expect(manage.readReleaseNotes("Summary.\n\n### Added\n- Thing")).toEqual({
|
||||||
|
summary: "Summary.",
|
||||||
|
changes: [{ type: "added", text: "Thing" }],
|
||||||
|
});
|
||||||
|
expect(manage.readReleaseNotes("")).toBeUndefined();
|
||||||
|
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
validateManifest,
|
validateManifest,
|
||||||
SUPPORTED_PLUGIN_API_VERSION,
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
isTermixCompatible,
|
isTermixCompatible,
|
||||||
|
youtubeVideoId,
|
||||||
} from "../../plugins/manifest.js";
|
} from "../../plugins/manifest.js";
|
||||||
|
|
||||||
function base(overrides: Record<string, unknown> = {}) {
|
function base(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -852,3 +853,47 @@ describe("contributes.protocols", () => {
|
|||||||
expect(errors.join("\n")).toMatch(/extra/);
|
expect(errors.join("\n")).toMatch(/extra/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("manifest video", () => {
|
||||||
|
it("accepts YouTube links", () => {
|
||||||
|
for (const video of [
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://youtu.be/dQw4w9WgXcQ",
|
||||||
|
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||||
|
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
||||||
|
]) {
|
||||||
|
expect(validateManifest(base({ video }))).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything that is not a YouTube video", () => {
|
||||||
|
for (const video of [
|
||||||
|
"https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||||
|
"http://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://www.youtube.com.evil.example/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://user@www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://www.youtube.com/watch?v=short",
|
||||||
|
"https://www.youtube.com/channel/UC123",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
42,
|
||||||
|
]) {
|
||||||
|
expect(validateManifest(base({ video }))).toEqual([
|
||||||
|
expect.stringContaining('Field "video" must be a YouTube link'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("youtubeVideoId", () => {
|
||||||
|
it("returns only the id", () => {
|
||||||
|
expect(
|
||||||
|
youtubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42"),
|
||||||
|
).toBe("dQw4w9WgXcQ");
|
||||||
|
expect(youtubeVideoId("https://youtu.be/dQw4w9WgXcQ?si=abc")).toBe(
|
||||||
|
"dQw4w9WgXcQ",
|
||||||
|
);
|
||||||
|
expect(youtubeVideoId("https://youtu.be/../../x")).toBeNull();
|
||||||
|
expect(youtubeVideoId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,6 +121,28 @@ describe("parseRegistryIndex", () => {
|
|||||||
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps release notes and only a YouTube video id", () => {
|
||||||
|
const NOTES = "### Added\n- Thing";
|
||||||
|
const index = parseRegistryIndex({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "docker",
|
||||||
|
video: "https://youtu.be/dQw4w9WgXcQ",
|
||||||
|
versions: [version({ notes: NOTES })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
video: "https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||||
|
versions: [version({ notes: "x".repeat(30_000) })],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(index.plugins[0].videoId).toBe("dQw4w9WgXcQ");
|
||||||
|
expect(index.plugins[0].versions[0].notes).toBe(NOTES);
|
||||||
|
expect(index.plugins[1].videoId).toBeUndefined();
|
||||||
|
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses something that is not an index", () => {
|
it("refuses something that is not an index", () => {
|
||||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { rbacApi } from "@/main-axios";
|
import { rbacApi } from "@/main-axios";
|
||||||
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
|
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
|
||||||
|
import type {
|
||||||
|
ChangelogRelease,
|
||||||
|
ReleaseNotes,
|
||||||
|
} from "@termix-ssh/plugin-sdk/changelog";
|
||||||
|
|
||||||
export type { PluginChoice };
|
export type { PluginChoice };
|
||||||
|
|
||||||
@@ -246,6 +250,8 @@ export interface PluginSummary {
|
|||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
|
/** A YouTube video id from the manifest. Admins only. */
|
||||||
|
videoId?: string;
|
||||||
signedBy?: string | null;
|
signedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +273,7 @@ export interface RegistryPluginVersion {
|
|||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
releaseNotesUrl?: string;
|
releaseNotesUrl?: string;
|
||||||
|
notes?: ReleaseNotes;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +285,7 @@ export interface RegistryPluginEntry {
|
|||||||
category: string;
|
category: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
videoId?: string;
|
||||||
versions: RegistryPluginVersion[];
|
versions: RegistryPluginVersion[];
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
@@ -448,6 +456,13 @@ export async function getPluginData(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPluginChangelog(
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<ChangelogRelease[]> {
|
||||||
|
const response = await rbacApi.get(`${pluginPath(pluginId)}/changelog`);
|
||||||
|
return response.data?.releases ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePluginData(pluginId: string): Promise<void> {
|
export async function deletePluginData(pluginId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
|
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
|
||||||
|
|||||||
+13
-1
@@ -2105,8 +2105,20 @@
|
|||||||
"switchVersion": "Switch",
|
"switchVersion": "Switch",
|
||||||
"latestVersion": "{{version}} (latest)",
|
"latestVersion": "{{version}} (latest)",
|
||||||
"versionChange": "{{from}} to {{to}}",
|
"versionChange": "{{from}} to {{to}}",
|
||||||
"versionHistory": "Version history",
|
|
||||||
"releaseNotes": "Release notes",
|
"releaseNotes": "Release notes",
|
||||||
|
"noReleaseNotes": "No notes for this version.",
|
||||||
|
"viewRelease": "View on GitHub",
|
||||||
|
"showAllReleases": "Show all {{count}}",
|
||||||
|
"showFewerReleases": "Show fewer",
|
||||||
|
"videoTitle": "{{name}} video",
|
||||||
|
"changeTypes": {
|
||||||
|
"added": "Added",
|
||||||
|
"changed": "Changed",
|
||||||
|
"fixed": "Fixed",
|
||||||
|
"removed": "Removed",
|
||||||
|
"deprecated": "Deprecated",
|
||||||
|
"security": "Security"
|
||||||
|
},
|
||||||
"installedBadge": "Installed",
|
"installedBadge": "Installed",
|
||||||
"incompatible": "Needs a newer Termix",
|
"incompatible": "Needs a newer Termix",
|
||||||
"whatItCanDo": "What it can do",
|
"whatItCanDo": "What it can do",
|
||||||
|
|||||||
@@ -37,17 +37,12 @@ import {
|
|||||||
type PluginEntry,
|
type PluginEntry,
|
||||||
} from "./plugin-model";
|
} from "./plugin-model";
|
||||||
import { openPluginSettings } from "./open-plugins";
|
import { openPluginSettings } from "./open-plugins";
|
||||||
|
import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
|
||||||
import type { PluginsManager } from "./use-plugins-manager";
|
import type { PluginsManager } from "./use-plugins-manager";
|
||||||
|
|
||||||
const LINK_ROW =
|
const LINK_ROW =
|
||||||
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return "";
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PluginDetail({
|
export function PluginDetail({
|
||||||
plugin,
|
plugin,
|
||||||
manager,
|
manager,
|
||||||
@@ -206,6 +201,8 @@ export function PluginDetail({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||||
|
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||||
|
|
||||||
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{orderByRisk(plugin.capabilities).map((capability) => (
|
{orderByRisk(plugin.capabilities).map((capability) => (
|
||||||
@@ -224,48 +221,7 @@ export function PluginDetail({
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{plugin.versions.length > 0 && (
|
<PluginReleaseNotes plugin={plugin} />
|
||||||
<SectionCard
|
|
||||||
title={t("plugins.manager.versionHistory")}
|
|
||||||
icon={null}
|
|
||||||
>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{plugin.versions.map((v) => (
|
|
||||||
<div
|
|
||||||
key={v.version}
|
|
||||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-semibold">{v.version}</span>
|
|
||||||
{v.version === plugin.version && (
|
|
||||||
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
|
|
||||||
{t("plugins.manager.installedBadge")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!v.compatible && (
|
|
||||||
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
|
|
||||||
{t("plugins.manager.incompatible")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
|
||||||
{formatDate(v.publishedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{v.releaseNotesUrl && (
|
|
||||||
<a
|
|
||||||
href={v.releaseNotesUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
|
||||||
>
|
|
||||||
{t("plugins.manager.releaseNotes")}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{plugin.installed && (
|
{plugin.installed && (
|
||||||
<DataCard
|
<DataCard
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { getPluginChangelog } from "@/api/plugins-api";
|
||||||
|
import type { ChangelogRelease } from "@termix-ssh/plugin-sdk/changelog";
|
||||||
|
import {
|
||||||
|
groupChanges,
|
||||||
|
mergeReleaseNotes,
|
||||||
|
youtubeEmbedUrl,
|
||||||
|
type PluginEntry,
|
||||||
|
type ReleaseRow,
|
||||||
|
} from "./plugin-model";
|
||||||
|
|
||||||
|
const SHOWN = 3;
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backticks become code, everything else stays plain text. */
|
||||||
|
function InlineText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.split(/(`[^`]+`)/).map((part, i) =>
|
||||||
|
part.length > 2 && part.startsWith("`") && part.endsWith("`") ? (
|
||||||
|
<code
|
||||||
|
key={i}
|
||||||
|
className="bg-muted px-1 py-px font-mono text-[10px] text-foreground"
|
||||||
|
>
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Release({
|
||||||
|
row,
|
||||||
|
installedVersion,
|
||||||
|
}: {
|
||||||
|
row: ReleaseRow;
|
||||||
|
installedVersion: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const groups = groupChanges(row.changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold">{row.version}</span>
|
||||||
|
{row.version === installedVersion && (
|
||||||
|
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
|
||||||
|
{t("plugins.manager.installedBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!row.compatible && (
|
||||||
|
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
|
||||||
|
{t("plugins.manager.incompatible")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
{formatDate(row.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.summary && (
|
||||||
|
<p className="whitespace-pre-line text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
<InlineText text={row.summary} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.type} className="flex flex-col gap-1">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{t(`plugins.manager.changeTypes.${group.type}`)}
|
||||||
|
</span>
|
||||||
|
<ul className="flex list-disc flex-col gap-0.5 pl-4 text-[11px] leading-snug">
|
||||||
|
{group.items.map((item, i) => (
|
||||||
|
<li key={i} className="whitespace-pre-line">
|
||||||
|
<InlineText text={item} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{groups.length === 0 && !row.summary && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{t("plugins.manager.noReleaseNotes")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.releaseNotesUrl && (
|
||||||
|
<a
|
||||||
|
href={row.releaseNotesUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
||||||
|
>
|
||||||
|
{t("plugins.manager.viewRelease")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every version's notes, from the registry and the installed CHANGELOG.md.
|
||||||
|
* Renders nothing when there are no versions to list.
|
||||||
|
*/
|
||||||
|
export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [local, setLocal] = useState<ChangelogRelease[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLocal([]);
|
||||||
|
if (!plugin.installed) return;
|
||||||
|
getPluginChangelog(plugin.id)
|
||||||
|
.then((releases) => {
|
||||||
|
if (!cancelled) setLocal(releases);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [plugin.id, plugin.installed, plugin.version]);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => mergeReleaseNotes(plugin.versions, local),
|
||||||
|
[plugin.versions, local],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const shown = expanded ? rows : rows.slice(0, SHOWN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title={t("plugins.manager.releaseNotes")}
|
||||||
|
icon={null}
|
||||||
|
action={
|
||||||
|
rows.length > SHOWN ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? t("plugins.manager.showFewerReleases")
|
||||||
|
: t("plugins.manager.showAllReleases", { count: rows.length })}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="-mx-3 -my-1 divide-y divide-border md:-mx-4">
|
||||||
|
{shown.map((row) => (
|
||||||
|
<Release
|
||||||
|
key={row.version}
|
||||||
|
row={row}
|
||||||
|
installedVersion={plugin.version}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugin's YouTube video. Nothing is loaded when it has none. */
|
||||||
|
export function PluginVideo({
|
||||||
|
videoId,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
videoId?: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const src = youtubeEmbedUrl(videoId);
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="aspect-video w-full border border-border bg-black">
|
||||||
|
<iframe
|
||||||
|
src={src}
|
||||||
|
title={t("plugins.manager.videoTitle", { name })}
|
||||||
|
className="size-full"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
allow="encrypted-media; picture-in-picture; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@ import {
|
|||||||
getCapabilityInfo,
|
getCapabilityInfo,
|
||||||
type CapabilityRisk,
|
type CapabilityRisk,
|
||||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||||
|
import semver from "semver";
|
||||||
|
import {
|
||||||
|
CHANGE_TYPES,
|
||||||
|
type ChangeType,
|
||||||
|
type ChangelogChange,
|
||||||
|
type ChangelogRelease,
|
||||||
|
} from "@termix-ssh/plugin-sdk/changelog";
|
||||||
import type {
|
import type {
|
||||||
PluginSummary,
|
PluginSummary,
|
||||||
PluginUploadPreview,
|
PluginUploadPreview,
|
||||||
@@ -21,6 +28,7 @@ export interface PluginEntry {
|
|||||||
category: string;
|
category: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
|
videoId?: string;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
@@ -80,6 +88,7 @@ export function mergePlugins(
|
|||||||
category: entry?.category ?? "",
|
category: entry?.category ?? "",
|
||||||
icon: summary.icon ?? entry?.icon,
|
icon: summary.icon ?? entry?.icon,
|
||||||
repository: summary.repository ?? entry?.repository,
|
repository: summary.repository ?? entry?.repository,
|
||||||
|
videoId: summary.videoId ?? entry?.videoId,
|
||||||
installed: true,
|
installed: true,
|
||||||
version: summary.version,
|
version: summary.version,
|
||||||
latestVersion: entry?.latestVersion ?? null,
|
latestVersion: entry?.latestVersion ?? null,
|
||||||
@@ -115,6 +124,7 @@ export function mergePlugins(
|
|||||||
category: entry.category,
|
category: entry.category,
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
repository: entry.repository,
|
repository: entry.repository,
|
||||||
|
videoId: entry.videoId,
|
||||||
installed: false,
|
installed: false,
|
||||||
version: null,
|
version: null,
|
||||||
latestVersion: entry.latestVersion,
|
latestVersion: entry.latestVersion,
|
||||||
@@ -338,3 +348,73 @@ export function reportIssueUrl(
|
|||||||
});
|
});
|
||||||
return `${repo}/issues/new?${params.toString()}`;
|
return `${repo}/issues/new?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One version in a plugin's release notes. */
|
||||||
|
export interface ReleaseRow {
|
||||||
|
version: string;
|
||||||
|
date: string | null;
|
||||||
|
summary?: string;
|
||||||
|
changes: ChangelogChange[];
|
||||||
|
/** False only for a registry version this build cannot run. */
|
||||||
|
compatible: boolean;
|
||||||
|
releaseNotesUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry's versions and the installed copy's CHANGELOG.md as one list,
|
||||||
|
* newest first. The installed copy's notes win for a version in both.
|
||||||
|
*/
|
||||||
|
export function mergeReleaseNotes(
|
||||||
|
versions: RegistryPluginVersion[],
|
||||||
|
local: ChangelogRelease[],
|
||||||
|
): ReleaseRow[] {
|
||||||
|
const rows = new Map<string, ReleaseRow>();
|
||||||
|
for (const v of versions) {
|
||||||
|
rows.set(v.version, {
|
||||||
|
version: v.version,
|
||||||
|
date: v.publishedAt ?? null,
|
||||||
|
summary: v.notes?.summary,
|
||||||
|
changes: v.notes?.changes ?? [],
|
||||||
|
compatible: v.compatible,
|
||||||
|
releaseNotesUrl: v.releaseNotesUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const release of local) {
|
||||||
|
const row = rows.get(release.version);
|
||||||
|
rows.set(release.version, {
|
||||||
|
version: release.version,
|
||||||
|
date: release.date ?? row?.date ?? null,
|
||||||
|
summary: release.summary ?? row?.summary,
|
||||||
|
changes:
|
||||||
|
release.changes.length > 0 ? release.changes : (row?.changes ?? []),
|
||||||
|
compatible: row?.compatible ?? true,
|
||||||
|
releaseNotesUrl: row?.releaseNotesUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...rows.values()].sort((a, b) =>
|
||||||
|
semver.valid(a.version) && semver.valid(b.version)
|
||||||
|
? semver.rcompare(a.version, b.version)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The changes of one release grouped by type, in the changelog's order. */
|
||||||
|
export function groupChanges(
|
||||||
|
changes: ChangelogChange[],
|
||||||
|
): Array<{ type: ChangeType; items: string[] }> {
|
||||||
|
const groups: Array<{ type: ChangeType; items: string[] }> = [];
|
||||||
|
for (const type of CHANGE_TYPES) {
|
||||||
|
const items = changes.filter((c) => c.type === type).map((c) => c.text);
|
||||||
|
if (items.length > 0) groups.push({ type, items });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||||
|
|
||||||
|
/** The embed address for a video id, or null for anything that is not one. */
|
||||||
|
export function youtubeEmbedUrl(videoId: string | undefined): string | null {
|
||||||
|
return videoId && YOUTUBE_ID.test(videoId)
|
||||||
|
? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||||
|
import type { PluginEntry } from "@/plugins/plugin-model";
|
||||||
|
|
||||||
|
const getPluginChangelog = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/api/plugins-api", () => ({ getPluginChangelog }));
|
||||||
|
|
||||||
|
const { PluginReleaseNotes, PluginVideo } =
|
||||||
|
await import("@/plugins/PluginReleaseNotes");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
getPluginChangelog.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function i18n() {
|
||||||
|
const instance = i18next.createInstance();
|
||||||
|
await instance.use(initReactI18next).init({
|
||||||
|
lng: "en",
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
plugins: {
|
||||||
|
manager: {
|
||||||
|
releaseNotes: "Release notes",
|
||||||
|
installedBadge: "Installed",
|
||||||
|
changeTypes: { added: "Added", fixed: "Fixed" },
|
||||||
|
videoTitle: "{{name}} video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin(overrides: Partial<PluginEntry> = {}): PluginEntry {
|
||||||
|
return {
|
||||||
|
id: "docker",
|
||||||
|
name: "Docker",
|
||||||
|
installed: true,
|
||||||
|
version: "1.0.0",
|
||||||
|
versions: [],
|
||||||
|
...overrides,
|
||||||
|
} as PluginEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PluginReleaseNotes", () => {
|
||||||
|
it("shows the installed copy's changelog grouped by type", async () => {
|
||||||
|
getPluginChangelog.mockResolvedValue([
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
changes: [
|
||||||
|
{ type: "added", text: "Runs `docker ps`" },
|
||||||
|
{ type: "fixed", text: "A crash" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={await i18n()}>
|
||||||
|
<PluginReleaseNotes plugin={plugin()} />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByText("A crash")).toBeTruthy());
|
||||||
|
expect(getPluginChangelog).toHaveBeenCalledWith("docker");
|
||||||
|
expect(screen.getByText("Added")).toBeTruthy();
|
||||||
|
expect(screen.getByText("docker ps").tagName).toBe("CODE");
|
||||||
|
expect(screen.getByText("Installed")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing for a plugin with no versions", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<I18nextProvider i18n={await i18n()}>
|
||||||
|
<PluginReleaseNotes plugin={plugin({ installed: false })} />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
expect(getPluginChangelog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PluginVideo", () => {
|
||||||
|
it("embeds a YouTube id", async () => {
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={await i18n()}>
|
||||||
|
<PluginVideo videoId="dQw4w9WgXcQ" name="Docker" />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
const frame = screen.getByTitle("Docker video") as HTMLIFrameElement;
|
||||||
|
expect(frame.src).toBe(
|
||||||
|
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads nothing without a valid id", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<I18nextProvider i18n={await i18n()}>
|
||||||
|
<PluginVideo videoId="https://evil.example" name="Docker" />
|
||||||
|
<PluginVideo name="Docker" />
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("iframe")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
describeContributions,
|
describeContributions,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatCount,
|
formatCount,
|
||||||
|
groupChanges,
|
||||||
matchesQuery,
|
matchesQuery,
|
||||||
|
mergeReleaseNotes,
|
||||||
mergePlugins,
|
mergePlugins,
|
||||||
orderByRisk,
|
orderByRisk,
|
||||||
pluginSource,
|
pluginSource,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
reportIssueUrl,
|
reportIssueUrl,
|
||||||
sortPlugins,
|
sortPlugins,
|
||||||
uploadEntry,
|
uploadEntry,
|
||||||
|
youtubeEmbedUrl,
|
||||||
} from "@/plugins/plugin-model";
|
} from "@/plugins/plugin-model";
|
||||||
|
|
||||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||||
@@ -283,3 +286,88 @@ describe("reportIssueUrl", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mergeReleaseNotes", () => {
|
||||||
|
const registryVersion = (version: string, extra = {}) => ({
|
||||||
|
version,
|
||||||
|
compatible: true,
|
||||||
|
capabilities: [],
|
||||||
|
size: 1,
|
||||||
|
publishedAt: "2026-10-01T00:00:00Z",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins registry and local notes newest first, local winning", () => {
|
||||||
|
const rows = mergeReleaseNotes(
|
||||||
|
[
|
||||||
|
registryVersion("1.2.0", {
|
||||||
|
compatible: false,
|
||||||
|
notes: { changes: [{ type: "added", text: "Registry" }] },
|
||||||
|
}),
|
||||||
|
registryVersion("1.0.0", {
|
||||||
|
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||||
|
notes: { changes: [{ type: "added", text: "Old" }] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
date: "2026-09-01",
|
||||||
|
summary: "First.",
|
||||||
|
changes: [{ type: "added", text: "Local" }],
|
||||||
|
},
|
||||||
|
{ version: "0.9.0", changes: [{ type: "fixed", text: "Beta" }] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]);
|
||||||
|
expect(rows[0]).toMatchObject({ compatible: false });
|
||||||
|
expect(rows[1]).toEqual({
|
||||||
|
version: "1.0.0",
|
||||||
|
date: "2026-09-01",
|
||||||
|
summary: "First.",
|
||||||
|
changes: [{ type: "added", text: "Local" }],
|
||||||
|
compatible: true,
|
||||||
|
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||||
|
});
|
||||||
|
expect(rows[2]).toMatchObject({ date: null, compatible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty with nothing to show", () => {
|
||||||
|
expect(mergeReleaseNotes([], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupChanges", () => {
|
||||||
|
it("groups by type in a fixed order", () => {
|
||||||
|
expect(
|
||||||
|
groupChanges([
|
||||||
|
{ type: "fixed", text: "b" },
|
||||||
|
{ type: "added", text: "a" },
|
||||||
|
{ type: "fixed", text: "c" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ type: "added", items: ["a"] },
|
||||||
|
{ type: "fixed", items: ["b", "c"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("youtubeEmbedUrl", () => {
|
||||||
|
it("builds a privacy embed from a video id only", () => {
|
||||||
|
expect(youtubeEmbedUrl("dQw4w9WgXcQ")).toBe(
|
||||||
|
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses anything that is not an id", () => {
|
||||||
|
for (const value of [
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
"https://evil.example/x",
|
||||||
|
"dQw4w9WgXcQ/../../x",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
expect(youtubeEmbedUrl(value)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user