feat: beta channels for core and plugins with one stable/beta release workflow

This commit is contained in:
LukeGus committed 2026-10-07 16:25:58 -05:00
1 parent 6a855c0fa8
commit f73c4b5ad6
55 files changed
+16200 -460

No files matched your search

+28 -61
View File
@@ -1,67 +1,37 @@
name: Beta feedback
description: A bug or feedback on a weekly beta build
labels: [bug, beta]
type: Bug
description: A bug, rough edge or idea from a Termix beta
labels: [beta]
body:
- type: markdown
attributes:
value: |
Thanks for testing a beta. Betas are weekly snapshots of active development (the `:beta` Docker tag and a rolling pre-release here), so things will break sometimes.
Thanks for testing a beta. Short reports are fine. If you opened this from Termix, the version and setup are already filled in.
- type: input
id: termix-version
attributes:
label: Beta build
description: Your image tag, or the version in Settings under About.
placeholder: "e.g. 26.10.0-beta.20261005"
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Does this happen on the latest stable release?
description: Check stable too if you can.
options:
- "Not tested on stable"
- "Yes, it happens on stable too"
- "No, only on this beta"
- "Not applicable, this is a new beta feature"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Where do you see the problem?
options:
- "Web browser"
- "Desktop app (Windows)"
- "Desktop app (macOS)"
- "Desktop app (Linux)"
- "Mobile app (iOS)"
- "Mobile app (Android)"
- "Other"
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Server install
description: How is the Termix server installed?
options:
- "Docker"
- "Manual build"
- "Proxmox community script"
- "Desktop app (built-in server)"
- "Not sure"
label: Beta version
description: Shown in Settings under Updates, or your image tag.
placeholder: "e.g. 26.11.0-beta.2"
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Describe the bug or feedback. Screenshots help a lot.
description: The bug, what felt off, or your idea. Screenshots help a lot.
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Does this happen on the latest stable release?
options:
- "Not tested on stable"
- "Yes, it happens on stable too"
- "No, only on this beta"
- "Not a bug, just feedback"
validations:
required: false
- type: textarea
id: steps
attributes:
@@ -73,11 +43,19 @@ body:
3.
validations:
required: false
- type: textarea
id: beta-plugins
attributes:
label: Plugins on a beta
description: Filled in by Termix. One plugin and version per line.
render: text
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment
description: Browser and OS, plus anything special about your setup.
description: Browser or desktop app, OS, and how the server is installed.
validations:
required: false
- type: textarea
@@ -88,21 +66,10 @@ body:
render: text
validations:
required: false
- type: textarea
id: context
attributes:
label: Anything else?
description: Links, config, or anything that might help.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Before you submit
options:
- label: I searched open and closed issues and this is not a duplicate
required: true
- label: I am on the latest version, or I said which version I am on
required: true
- label: I understand beta builds are not stable
- label: I searched open issues and this is not a duplicate
required: true
-202
View File
@@ -1,202 +0,0 @@
name: Weekly Beta Release
on:
schedule:
- cron: "15 6 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Build and test but do not push images, upload installers, or publish a release"
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
prep:
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
dev_branch: ${{ steps.dev.outputs.branch }}
beta_version: ${{ steps.dev.outputs.beta_version }}
sha: ${{ steps.dev.outputs.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Resolve newest dev branch and compute beta version
id: dev
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
echo "Newest dev branch: $DEV_BRANCH"
else
echo "No dev-X.Y.Z branch open; nothing to snapshot for this week's beta."
echo "branch=" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE_VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
BETA_VERSION="${BASE_VERSION}-beta.$(date -u +%Y%m%d)"
SHA=$(gh api "repos/${{ github.repository }}/branches/$DEV_BRANCH" -q .commit.sha)
echo "Beta version: $BETA_VERSION"
echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
verify:
needs: [prep]
if: ${{ needs.prep.outputs.dev_branch != '' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build plugin SDK
run: npm run build:sdk
- name: Run ESLint
run: npx eslint .
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test
create-release:
needs: [prep, verify]
if: ${{ needs.prep.outputs.dev_branch != '' && inputs.dry_run != true }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout dev branch
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 0
- name: Resolve previous beta commit
id: prev
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
SHA="${{ needs.prep.outputs.sha }}"
PREV_SHA=$(gh release view beta --repo ${{ github.repository }} --json targetCommitish -q .targetCommitish 2>/dev/null || true)
if [ -z "$PREV_SHA" ] || ! git cat-file -e "$PREV_SHA^{commit}" 2>/dev/null; then
PREV_SHA=$(git rev-parse -q --verify "refs/tags/beta^{commit}" || true)
fi
# The last beta may come from an older dev branch, so diff from where the two meet.
if [ -n "$PREV_SHA" ] && ! git merge-base --is-ancestor "$PREV_SHA" "$SHA"; then
PREV_SHA=$(git merge-base "$PREV_SHA" "$SHA" || true)
fi
echo "Previous beta commit: ${PREV_SHA:-none}"
echo "sha=$PREV_SHA" >> "$GITHUB_OUTPUT"
- name: Generate rolling beta release notes
run: |
PREV="${{ steps.prev.outputs.sha }}"
SHA="${{ needs.prep.outputs.sha }}"
MAX=150
if [ -z "$PREV" ]; then
CHANGES="- Could not find the previous beta commit."
else
LOG=$(git log --oneline --no-merges "$PREV..$SHA" -- . ':!package-lock.json')
COUNT=$(printf '%s' "$LOG" | grep -c . || true)
if [ "$COUNT" -eq 0 ]; then
CHANGES="- No new commits since the last beta."
else
CHANGES=$(printf '%s\n' "$LOG" | head -n "$MAX" | sed 's/^/- /')
if [ "$COUNT" -gt "$MAX" ]; then
CHANGES="$CHANGES
- ...and $((COUNT - MAX)) more"
fi
CHANGES="$CHANGES
[Full diff](https://github.com/${{ github.repository }}/compare/$PREV...$SHA)"
fi
fi
cat > BETA_RELEASE_BODY.md << EOF
> [!WARNING]
> This is an automated weekly beta build, snapshotted from the \`${{ needs.prep.outputs.dev_branch }}\` branch. It is not a stable release: it may contain unfinished features, regressions, or breaking changes, and this tag is overwritten every week. Do not run it in production.
>
> Found a bug? [Open a Beta Feedback report](https://github.com/Termix-SSH/Termix/issues/new?template=beta_feedback.yml) and mention this build: \`${{ needs.prep.outputs.beta_version }}\`.
**Snapshot of:** \`${{ needs.prep.outputs.dev_branch }}\` @ \`${{ needs.prep.outputs.sha }}\`
**Docker image:** \`ghcr.io/termix-ssh/termix:beta\` / \`docker.io/termixssh/termix:beta\` (rolling), or pin to \`:beta-${{ needs.prep.outputs.beta_version }}\` for this exact build.
**Built:** $(date -u +"%Y-%m-%d %H:%M UTC")
### Changes since last beta
$CHANGES
EOF
- name: Create or update rolling beta release
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
TAG="beta"
TITLE="Beta (rolling) - ${{ needs.prep.outputs.beta_version }}"
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
# --target only applies to a new tag, so move the existing one by hand.
gh api -X PATCH "repos/${{ github.repository }}/git/refs/tags/$TAG" \
-f sha="${{ needs.prep.outputs.sha }}" -F force=true >/dev/null
gh release edit "$TAG" --repo ${{ github.repository }} \
--title "$TITLE" --notes-file BETA_RELEASE_BODY.md \
--prerelease --target "${{ needs.prep.outputs.sha }}"
else
gh release create "$TAG" --repo ${{ github.repository }} \
--title "$TITLE" --notes-file BETA_RELEASE_BODY.md \
--prerelease --target "${{ needs.prep.outputs.sha }}"
fi
docker:
needs: [prep, verify, create-release]
if: ${{ always() && needs.prep.outputs.dev_branch != '' && needs.verify.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prep.outputs.beta_version }}
build_type: Beta
dry_run: ${{ inputs.dry_run == true }}
source_ref: ${{ needs.prep.outputs.sha }}
secrets: inherit
electron-release:
needs: [prep, verify, create-release]
if: ${{ always() && needs.prep.outputs.dev_branch != '' && inputs.dry_run != true && needs.create-release.result == 'success' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: release
release_tag: beta
version_override: ${{ needs.prep.outputs.beta_version }}
source_ref: ${{ needs.prep.outputs.sha }}
secrets: inherit
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "beta-$VERSION")
TAGS+=("beta" "$VERSION")
else
TAGS+=("dev-$VERSION")
fi
+203 -29
View File
@@ -1,10 +1,24 @@
name: Release
# Stable: run by hand from a dev-X.Y.Z branch. Beta: run by hand from a dev
# branch, or let the Tuesday and Friday schedule snapshot the newest dev
# branch when it moved since the last beta.
on:
schedule:
- cron: "0 15 * * 2,5"
workflow_dispatch:
inputs:
channel:
description: "Stable release or beta"
required: true
default: Stable
type: choice
options:
- Stable
- Beta
mode:
description: "Release mode"
description: "Release mode (Overwrite release is stable only)"
required: true
default: Everything
type: choice
@@ -17,24 +31,45 @@ on:
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
prep:
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
version: ${{ steps.info.outputs.version }}
release_tag: ${{ steps.info.outputs.release_tag }}
channel: ${{ steps.mode.outputs.channel }}
mode: ${{ steps.mode.outputs.mode }}
skip: ${{ steps.beta.outputs.skip || 'false' }}
version: ${{ steps.info.outputs.version || steps.beta.outputs.version }}
release_tag: ${{ steps.info.outputs.release_tag || steps.beta.outputs.release_tag }}
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.beta.outputs.dev_branch }}
sha: ${{ steps.beta.outputs.sha }}
prev_ref: ${{ steps.beta.outputs.prev_ref }}
steps:
- name: Resolve channel and mode
id: mode
run: |
CHANNEL="${{ inputs.channel || 'Beta' }}"
MODE="${{ inputs.mode || 'Everything' }}"
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
if [ "$CHANNEL" = "Beta" ] && [ "$MODE" = "Overwrite release" ]; then
echo "Overwrite release is for stable releases only."
exit 1
fi
- name: Guard branch (release modes)
if: ${{ inputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
if: ${{ github.event_name == 'workflow_dispatch' && steps.mode.outputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
run: |
echo "This mode must be run from a dev branch (got ${{ github.ref }})."
exit 1
- name: Guard branch (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
if: ${{ steps.mode.outputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
run: |
echo "Overwrite release must be run from main (got ${{ github.ref }})."
exit 1
@@ -51,10 +86,11 @@ jobs:
- name: Resolve versions
id: info
if: ${{ steps.mode.outputs.channel == 'Stable' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
if [ "${{ steps.mode.outputs.mode }}" = "Overwrite release" ]; then
DEV_BRANCH=""
VERSION=$(node -p "require('./package.json').version")
else
@@ -75,15 +111,94 @@ jobs:
echo "mobile_tag=$MOBILE_RAW" >> "$GITHUB_OUTPUT"
- name: Check CHANGELOG.md has notes for this version
if: ${{ steps.mode.outputs.channel == 'Stable' }}
run: |
node scripts/generate-release-body.cjs \
--version "${{ steps.info.outputs.version }}" \
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
--mobile-tag "${{ steps.info.outputs.mobile_tag }}" > /dev/null
- name: Resolve beta version
id: beta
if: ${{ steps.mode.outputs.channel == 'Beta' }}
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
if ! DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
echo "No dev-X.Y.Z branch is open, so no beta today."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
else
DEV_BRANCH="${GITHUB_REF#refs/heads/}"
fi
BASE=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
SHA=$(gh api "repos/${{ github.repository }}/branches/$DEV_BRANCH" -q .commit.sha)
TAGS=$(gh api "repos/${{ github.repository }}/git/matching-refs/tags/v$BASE-beta." -q '.[].ref' | sed 's#^refs/tags/##')
LATEST=$(printf '%s\n' "$TAGS" | node scripts/beta-version.cjs "$BASE" --latest)
VERSION=$(printf '%s\n' "$TAGS" | node scripts/beta-version.cjs "$BASE")
if [ -n "$LATEST" ]; then
LATEST_SHA=$(gh api "repos/${{ github.repository }}/commits/$LATEST" -q .sha)
if [ "$LATEST_SHA" = "$SHA" ] && [ "${{ github.event_name }}" = "schedule" ]; then
echo "$DEV_BRANCH has not moved since $LATEST, so no beta today."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
PREV="$LATEST"
else
PREV=$(gh release view --repo ${{ github.repository }} --json tagName -q .tagName 2>/dev/null || true)
fi
echo "Beta $VERSION from $DEV_BRANCH @ $SHA, changes since ${PREV:-nothing}"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
verify-beta:
needs: [prep]
if: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.skip != 'true' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout beta commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.sha }}
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build plugin SDK
run: npm run build:sdk
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test
normalize:
needs: [prep]
if: ${{ inputs.mode != 'Overwrite release' }}
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
@@ -128,7 +243,7 @@ jobs:
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
if [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:"
git --no-pager diff --stat
exit 0
@@ -140,7 +255,7 @@ jobs:
crowdin:
needs: [prep, normalize]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' }}
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
@@ -222,7 +337,7 @@ jobs:
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
@@ -254,14 +369,14 @@ jobs:
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
TITLE="v${{ needs.prep.outputs.version }}"
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
if [ "${{ needs.prep.outputs.mode }}" = "Overwrite release" ]; then
echo "OVERWRITE: no merge; building from main as-is."
echo "build_ref=main" >> "$GITHUB_OUTPUT"
echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
if [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main."
echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main."
echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
@@ -290,18 +405,19 @@ jobs:
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, merge-to-main]
needs: [prep, verify-beta, merge-to-main]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.skip != 'true' && (needs.merge-to-main.result == 'success' || needs.verify-beta.result == 'success') }}
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prep.outputs.version }}
build_type: Production
dry_run: ${{ inputs.mode == 'Dry run' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
build_type: ${{ needs.prep.outputs.channel == 'Beta' && 'Beta' || 'Production' }}
dry_run: ${{ needs.prep.outputs.mode == 'Dry run' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
secrets: inherit
create-release:
needs: [prep, merge-to-main, docker]
if: ${{ inputs.mode != 'Dry run' }}
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
@@ -318,7 +434,7 @@ jobs:
node-version-file: ".nvmrc"
- name: Clear existing release artifacts (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' }}
if: ${{ needs.prep.outputs.mode == 'Overwrite release' }}
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
@@ -333,7 +449,7 @@ jobs:
done
- name: Generate release body
if: ${{ inputs.mode != 'Overwrite release' }}
if: ${{ needs.prep.outputs.mode != 'Overwrite release' }}
run: |
node scripts/generate-release-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
@@ -341,7 +457,7 @@ jobs:
--mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > RELEASE_BODY.md
- name: Create or update GitHub release
if: ${{ inputs.mode != 'Overwrite release' }}
if: ${{ needs.prep.outputs.mode != 'Overwrite release' }}
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
@@ -353,20 +469,63 @@ jobs:
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}"
fi
create-beta-release:
needs: [prep, docker]
if: ${{ always() && needs.prep.outputs.channel == 'Beta' && needs.docker.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout beta commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prep.outputs.sha }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Generate beta release body
run: |
node scripts/generate-beta-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
--branch "${{ needs.prep.outputs.dev_branch }}" \
--sha "${{ needs.prep.outputs.sha }}" \
--prev "${{ needs.prep.outputs.prev_ref }}" > BETA_BODY.md
- name: Create beta release
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
VERSION="${{ needs.prep.outputs.version }}"
TITLE="Termix ${VERSION%-beta.*} Beta ${VERSION##*-beta.}"
TAG="${{ needs.prep.outputs.release_tag }}"
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
gh release edit "$TAG" --repo ${{ github.repository }} \
--title "$TITLE" --notes-file BETA_BODY.md --prerelease
else
gh release create "$TAG" --repo ${{ github.repository }} \
--title "$TITLE" --notes-file BETA_BODY.md \
--prerelease --target "${{ needs.prep.outputs.sha }}"
fi
electron-release:
needs: [prep, merge-to-main, create-release]
if: ${{ always() && needs.merge-to-main.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
needs: [prep, merge-to-main, docker, create-release, create-beta-release]
if: ${{ always() && needs.prep.outputs.skip != 'true' && needs.docker.result == 'success' && needs.create-release.result != 'failure' && needs.create-beta-release.result != 'failure' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
artifact_destination: ${{ needs.prep.outputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
version_override: ${{ needs.prep.outputs.channel == 'Beta' && needs.prep.outputs.version || '' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref || needs.prep.outputs.sha }}
secrets: inherit
electron-submit:
needs: [prep, merge-to-main, electron-release]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
@@ -376,7 +535,7 @@ jobs:
cask-commit-back:
needs: [prep, electron-release]
if: ${{ inputs.mode != 'Dry run' }}
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
@@ -416,9 +575,25 @@ jobs:
git commit -m "chore: bump Homebrew cask to $VERSION"
git push origin HEAD:main
prune-betas:
needs: [prep, create-release]
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.create-release.result == 'success' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Delete this version's beta releases (tags stay)
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
PREFIX="v${{ needs.prep.outputs.version }}-beta."
gh release list --repo ${{ github.repository }} --limit 200 --json tagName -q '.[].tagName' \
| grep -F "$PREFIX" | while read -r TAG; do
echo "Deleting release $TAG"
gh release delete "$TAG" --repo ${{ github.repository }} --yes || true
done
cleanup:
needs: [prep, merge-to-main, electron-release, cask-commit-back]
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }}
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && (needs.prep.outputs.mode == 'Everything' || needs.prep.outputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
@@ -433,4 +608,3 @@ jobs:
run: |
git push https://x-access-token:${{ secrets.TERMIX_PAT }}@github.com/${{ github.repository }}.git \
--delete "${{ needs.prep.outputs.dev_branch }}" || true
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `plugins` ADD `channel` text DEFAULT ('stable') NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -484,6 +484,13 @@
"when": 1791256341049,
"tag": "0068_plugin_pinned_version",
"breakpoints": true
},
{
"idx": 69,
"version": "5",
"when": 1791388888997,
"tag": "0069_plugin_channel",
"breakpoints": true
}
]
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "plugins" ADD COLUMN "channel" text DEFAULT 'stable' NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -484,6 +484,13 @@
"when": 1791256340388,
"tag": "0068_plugin_pinned_version",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1791388888397,
"tag": "0069_plugin_channel",
"breakpoints": true
}
]
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `plugins` ADD `channel` text DEFAULT 'stable' NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -449,6 +449,13 @@
"when": 1791256339684,
"tag": "0063_plugin_pinned_version",
"breakpoints": true
},
{
"idx": 64,
"version": "6",
"when": 1791388887785,
"tag": "0064_plugin_channel",
"breakpoints": true
}
]
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Works out beta numbers from a list of tags on stdin.
*
* node scripts/beta-version.cjs 26.11.0 < tags.txt -> 26.11.0-beta.3
* node scripts/beta-version.cjs 26.11.0 --latest < tags.txt -> v26.11.0-beta.2
*/
const fs = require("fs");
function betaNumbers(base, tags) {
const prefix = `v${base}-beta.`;
return tags
.map((tag) => tag.trim())
.filter((tag) => tag.startsWith(prefix))
.map((tag) => Number(tag.slice(prefix.length)))
.filter((n) => Number.isInteger(n) && n > 0)
.sort((a, b) => a - b);
}
function nextBeta(base, tags) {
const numbers = betaNumbers(base, tags);
return `${base}-beta.${(numbers.at(-1) ?? 0) + 1}`;
}
function latestBetaTag(base, tags) {
const numbers = betaNumbers(base, tags);
return numbers.length ? `v${base}-beta.${numbers.at(-1)}` : "";
}
if (require.main === module) {
const base = process.argv[2];
if (!base || !/^\d+\.\d+\.\d+$/.test(base)) {
console.error("beta-version: pass a version like 26.11.0");
process.exit(1);
}
const tags = fs.readFileSync(0, "utf8").split("\n");
console.log(
process.argv.includes("--latest")
? latestBetaTag(base, tags)
: nextBeta(base, tags),
);
}
module.exports = { latestBetaTag, nextBeta };
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { latestBetaTag, nextBeta } = require("./beta-version.cjs");
const TAGS = [
"v26.10.0",
"v26.11.0-beta.1",
"v26.11.0-beta.10",
"v26.11.0-beta.2",
"v26.11.0-beta.x",
"v26.12.0-beta.4",
];
describe("beta-version", () => {
it("numbers the next beta after the highest one, not the last listed", () => {
expect(nextBeta("26.11.0", TAGS)).toBe("26.11.0-beta.11");
expect(latestBetaTag("26.11.0", TAGS)).toBe("v26.11.0-beta.10");
});
it("starts at 1 for a version with no betas", () => {
expect(nextBeta("27.1.0", TAGS)).toBe("27.1.0-beta.1");
expect(latestBetaTag("27.1.0", TAGS)).toBe("");
});
});
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Writes the body of a beta release: what a beta is, how to get it, how to
* leave, and the commits since the previous beta (or stable release) grouped
* by their feat:/fix: prefix.
*
* node scripts/generate-beta-body.cjs --version 26.11.0-beta.2 \
* --branch dev-26.11.0 --sha <head> [--prev <ref>]
*/
const { execFileSync } = require("child_process");
const REPO = "Termix-SSH/Termix";
const MAX_COMMITS = 150;
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (!argv[i].startsWith("--")) continue;
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
args[argv[i].slice(2)] = true;
} else {
args[argv[i].slice(2)] = next;
i++;
}
}
return args;
}
/** Commit subjects sorted into features, fixes and everything else. */
function groupCommits(subjects) {
const groups = { features: [], fixes: [], other: [] };
for (const subject of subjects) {
const text = subject.trim();
if (!text) continue;
const match = /^(\w+)(\([^)]*\))?!?:\s*(.+)$/.exec(text);
const type = match?.[1].toLowerCase();
const message = match ? match[3] : text;
if (type === "feat") groups.features.push(message);
else if (type === "fix") groups.fixes.push(message);
else groups.other.push(message);
}
return groups;
}
/** "26.11.0-beta.2" reads as "26.11.0 Beta 2". */
function betaTitle(version) {
const match = /^(.+)-beta\.(\d+)$/.exec(version);
return match ? `${match[1]} Beta ${match[2]}` : version;
}
function renderBody({ version, branch, sha, prev, subjects, repo = REPO }) {
const shown = subjects.slice(0, MAX_COMMITS);
const groups = groupCommits(shown);
const list = (items) => items.map((item) => `- ${item}`).join("\n");
const sections = [
["Features", groups.features],
["Fixes", groups.fixes],
["Other changes", groups.other],
]
.filter(([, items]) => items.length > 0)
.map(([title, items]) => `### ${title}\n\n${list(items)}`);
let changes;
if (!prev) {
changes = "First beta of this version.";
} else if (sections.length === 0) {
changes = "No changes since the last release.";
} else {
changes = sections.join("\n\n");
if (subjects.length > MAX_COMMITS) {
changes += `\n\n...and ${subjects.length - MAX_COMMITS} more.`;
}
changes += `\n\n[Full diff](https://github.com/${repo}/compare/${prev}...${sha})`;
}
return `> [!WARNING]
> This is a beta of the next Termix release, built from \`${branch}\`. It can have bugs, so keep a backup and don't use it where you can't afford problems.
**Found a bug or have feedback?** [Send beta feedback](https://github.com/${repo}/issues/new?template=beta_feedback.yml&termix-version=${encodeURIComponent(version)}). It is the fastest way to get it fixed before the stable release.
### How to get it
- **Docker:** \`ghcr.io/termix-ssh/termix:beta\` follows every beta. \`ghcr.io/termix-ssh/termix:${version}\` stays on this one.
- **Desktop:** download an installer from the assets below.
- **Going back:** switch to the \`latest\` image or a stable installer. Keep a backup of your data folder, since a beta can change the database.
## Changes in ${betaTitle(version)}
${changes}
`;
}
function readSubjects(prev, sha) {
if (!prev) return [];
const out = execFileSync(
"git",
[
"log",
"--no-merges",
"--format=%s",
`${prev}..${sha}`,
"--",
".",
":!package-lock.json",
],
{ encoding: "utf8" },
);
return out.split("\n").filter(Boolean);
}
if (require.main === module) {
const args = parseArgs(process.argv.slice(2));
if (!args.version || !args.branch || !args.sha) {
console.error(
"generate-beta-body: --version, --branch and --sha are required",
);
process.exit(1);
}
const prev = typeof args.prev === "string" ? args.prev : "";
process.stdout.write(
renderBody({
version: args.version,
branch: args.branch,
sha: args.sha,
prev,
subjects: readSubjects(prev, args.sha),
}),
);
}
module.exports = { betaTitle, groupCommits, renderBody };
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
betaTitle,
groupCommits,
renderBody,
} = require("./generate-beta-body.cjs");
describe("generate-beta-body", () => {
it("groups commits by their prefix", () => {
expect(
groupCommits([
"feat: add beta channel",
"fix(sync): keep links",
"chore: bump deps",
"Plain message",
]),
).toEqual({
features: ["add beta channel"],
fixes: ["keep links"],
other: ["bump deps", "Plain message"],
});
});
it("names the beta", () => {
expect(betaTitle("26.11.0-beta.2")).toBe("26.11.0 Beta 2");
});
it("links feedback with the version and lists the changes", () => {
const body = renderBody({
version: "26.11.0-beta.2",
branch: "dev-26.11.0",
sha: "abc",
prev: "v26.11.0-beta.1",
subjects: ["feat: one", "fix: two"],
});
expect(body).toContain("termix-version=26.11.0-beta.2");
expect(body).toContain("## Changes in 26.11.0 Beta 2");
expect(body).toContain("### Features\n\n- one");
expect(body).toContain("### Fixes\n\n- two");
expect(body).toContain("compare/v26.11.0-beta.1...abc");
expect(body).not.toContain("—");
});
it("says so when there is nothing to compare against", () => {
const body = renderBody({
version: "26.11.0-beta.1",
branch: "dev-26.11.0",
sha: "abc",
prev: "",
subjects: [],
});
expect(body).toContain("First beta of this version.");
});
});
@@ -110,6 +110,12 @@ function adminSections() {
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
"utf8",
),
),
};
}
+150 -43
View File
@@ -47,11 +47,19 @@ import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
compareSemver,
fetchGitHubAPI,
fetchLatestRelease,
isPrereleaseVersion,
REPO_NAME,
REPO_OWNER,
updateStatus,
} from "../utils/latest-release.js";
import {
getCoreChannel,
getStoredCoreChannel,
parseChannel,
setCoreChannel,
} from "../updates/update-channel.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
@@ -242,7 +250,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* /version:
* get:
* summary: Get version information
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the latest GitHub release and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the newest GitHub release on the instance's update channel and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* tags:
* - General
* parameters:
@@ -271,9 +279,13 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* type: string
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
* enum: [up_to_date, beta, requires_update, update_check_disabled, unknown]
* channel:
* type: string
* enum: [stable, beta]
* description: The update channel the check used. Always beta on a beta build.
* remoteVersion:
* type: string
* description: Latest GitHub release version. Present only when the update check succeeds.
* description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0
* version:
* type: string
@@ -296,12 +308,8 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* html_url:
* type: string
* format: uri
* cached:
* type: boolean
* description: Whether the release lookup used a cached response. Present only when the update check succeeds.
* cache_age:
* type: number
* description: Age of the cached response in milliseconds, when available.
* prerelease:
* type: boolean
* 404:
* description: Local version not set.
*/
@@ -320,48 +328,31 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const channel = await getCoreChannel(localVersion);
const latest = await fetchLatestRelease(channel);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
if (!latest) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
channel,
});
return res.json({ localVersion, status: "unknown" });
return res.json({ localVersion, status: "unknown", channel });
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = {
status,
localVersion: localVersion,
version: remoteVersion,
remoteVersion: remoteVersion,
res.json({
status: updateStatus(localVersion, latest.version),
channel,
localVersion,
version: latest.version,
remoteVersion: latest.version,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
tag_name: latest.tagName,
name: latest.name,
published_at: latest.publishedAt,
html_url: latest.url,
prerelease: latest.prerelease,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
});
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
@@ -370,6 +361,122 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /version/channel:
* get:
* summary: Get the update channel
* description: Which Termix releases this instance is told about. A beta build always reports beta; stored is the admin's choice for stable builds.
* tags:
* - General
* responses:
* 200:
* description: The channel.
* content:
* application/json:
* schema:
* type: object
* properties:
* channel:
* type: string
* enum: [stable, beta]
* stored:
* type: string
* enum: [stable, beta]
* runningBeta:
* type: boolean
* put:
* summary: Set the update channel
* description: Admin only. Switches which Termix releases update checks and the Updates page offer. It does not change the running build.
* tags:
* - General
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [channel]
* properties:
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The channel after the change.
* 400:
* description: Not a known channel.
*/
app.get("/version/channel", authenticateJWT, async (_req, res) => {
try {
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: await getStoredCoreChannel(),
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to read the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to read the update channel" });
}
});
app.put("/version/channel", requireAdmin, async (req, res) => {
const channel = parseChannel(req.body?.channel);
if (!channel) {
return res.status(400).json({ error: "channel must be stable or beta" });
}
try {
await setCoreChannel(channel);
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: channel,
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to save the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to save the update channel" });
}
});
/**
* @openapi
* /version/releases:
* get:
* summary: Get the newest stable and beta releases
* description: The newest stable Termix release and the newest beta, if one is newer than stable, for the Updates page.
* tags:
* - General
* responses:
* 200:
* description: The releases. Either may be null.
* 500:
* description: GitHub could not be reached.
*/
app.get("/version/releases", authenticateJWT, async (_req, res) => {
try {
const [stable, newest] = await Promise.all([
fetchLatestRelease("stable"),
fetchLatestRelease("beta"),
]);
res.json({
localVersion: getLocalVersion(),
stable,
beta: newest?.prerelease ? newest : null,
});
} catch (error) {
databaseLogger.error("Failed to read releases", error, {
operation: "version_releases",
});
res.status(500).json({ error: "Failed to read releases" });
}
});
/**
* @openapi
* /releases/rss:
+2
View File
@@ -509,6 +509,7 @@ async function initializeCompleteDatabase(): Promise<void> {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
pinned_version TEXT,
channel TEXT NOT NULL DEFAULT 'stable',
manifest_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
@@ -1607,6 +1608,7 @@ const migrateSchema = () => {
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists("plugins", "pinned_version", "TEXT");
addColumnIfNotExists("plugins", "channel", "TEXT NOT NULL DEFAULT 'stable'");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
+2
View File
@@ -1007,6 +1007,8 @@ export const plugins = mysqlTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1007,6 +1007,8 @@ export const plugins = pgTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
+2
View File
@@ -1005,6 +1005,8 @@ export const plugins = sqliteTable(
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -21,6 +21,7 @@ export interface PluginCreateInput {
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson: string;
}
@@ -34,6 +35,7 @@ export interface PluginUpdateInput {
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson?: string;
}
@@ -71,6 +73,7 @@ export class PluginRepository {
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
pinnedVersion: input.pinnedVersion ?? null,
channel: input.channel ?? "stable",
manifestJson: input.manifestJson,
installedAt: now,
updatedAt: now,
@@ -108,6 +111,7 @@ export class PluginRepository {
input.pinnedVersion === undefined
? existing.pinnedVersion
: input.pinnedVersion,
channel: input.channel ?? existing.channel,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
},
+92 -6
View File
@@ -18,6 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
import {
isPluginChoice,
@@ -317,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
registryId: record.registryId ?? null,
autoUpdate: Boolean(record.autoUpdate),
pinnedVersion: record.pinnedVersion ?? null,
channel: record.channel ?? "stable",
description,
author,
repository,
@@ -1644,6 +1646,65 @@ router.post(
},
);
/**
* @openapi
* /plugins/channel:
* post:
* summary: Move every installed plugin to one update channel
* description: Sets the channel on every installed plugin. Nothing is installed here; the next update check offers what the channel allows. Going back to stable never downgrades a plugin that is on a beta.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [channel]
* properties:
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The plugins whose channel changed.
* 400:
* description: Not a known channel.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: This desktop is linked to a server, which manages its plugins.
*/
router.post(
"/channel",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const channel = parsePluginChannel(req.body?.channel);
if (!channel) {
res.status(400).json({ error: "channel must be stable or beta" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { setAllPluginChannels } = await import("../../plugins/manage.js");
const result = await setAllPluginChannels(channel);
await auditPluginAction(req, "update_plugin_options", "*", {
channel,
changed: result.changed,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to change the plugin channel",
"plugin_options",
);
}
},
);
/**
* @openapi
* /plugins/{id}/install:
@@ -1677,6 +1738,10 @@ router.post(
* properties:
* version:
* type: string
* channel:
* type: string
* enum: [stable, beta]
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
* capabilities:
* type: array
* items:
@@ -1703,14 +1768,19 @@ router.post(
const pluginId = String(req.params.id);
const version = readVersion(req.body);
const capabilities = readCapabilities(req.body);
const channel =
req.body?.channel === undefined
? "stable"
: parsePluginChannel(req.body.channel);
if (
!ID_PATTERN.test(pluginId) ||
version === null ||
capabilities === null
capabilities === null ||
channel === null
) {
res
.status(400)
.json({ error: "Invalid plugin id, version or capabilities" });
.json({ error: "Invalid plugin id, version, channel or capabilities" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
@@ -1719,6 +1789,7 @@ router.post(
const userId = (req as AuthenticatedRequest).userId as string;
const result = await installPlugin(pluginId, {
version,
channel,
userId,
capabilities,
});
@@ -1891,7 +1962,7 @@ router.delete(
* /plugins/{id}/options:
* patch:
* summary: Change a plugin's update choices
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update.
* description: autoUpdate lets the background check apply new releases that ask for nothing new. pinned holds the plugin at its installed version, which also stops auto-update. channel picks whether betas are offered; going back to stable keeps an installed beta until a stable release passes it.
* tags:
* - Plugins
* parameters:
@@ -1911,6 +1982,9 @@ router.delete(
* type: boolean
* pinned:
* type: boolean
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The saved choices.
@@ -1928,16 +2002,28 @@ router.patch(
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const { autoUpdate, pinned } = req.body ?? {};
const channel =
req.body?.channel === undefined
? undefined
: parsePluginChannel(req.body.channel);
if (
(autoUpdate !== undefined && typeof autoUpdate !== "boolean") ||
(pinned !== undefined && typeof pinned !== "boolean")
(pinned !== undefined && typeof pinned !== "boolean") ||
channel === null
) {
res.status(400).json({ error: "autoUpdate and pinned must be booleans" });
res.status(400).json({
error:
"autoUpdate and pinned must be booleans and channel stable or beta",
});
return;
}
try {
const { setPluginOptions } = await import("../../plugins/manage.js");
const result = await setPluginOptions(pluginId, { autoUpdate, pinned });
const result = await setPluginOptions(pluginId, {
autoUpdate,
pinned,
channel,
});
await auditPluginAction(req, "update_plugin_options", pluginId, result);
res.json({ id: pluginId, ...result });
} catch (error) {
+5 -1
View File
@@ -482,7 +482,11 @@ export class PluginLoader {
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
if (
!semver.satisfies(dependency.manifest.version, range, {
includePrerelease: true,
})
) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
+70 -11
View File
@@ -32,10 +32,14 @@ import {
OFFICIAL_REGISTRY_ID,
downloadRelease,
fetchRegistryIndex,
channelReleases,
findRelease,
getDownloadsDir,
getRegistryStatus,
isApiCompatible,
latestOnChannel,
parsePluginChannel,
type PluginChannel,
type RegistryIndex,
type RegistryVersion,
} from "./registry-index.js";
@@ -192,8 +196,8 @@ export async function isLinkedDesktop(): Promise<boolean> {
return Boolean(await getLink());
}
function latestCompatible(versions: RegistryVersion[]): RegistryVersion | null {
return versions.find((entry) => isApiCompatible(entry.api)) ?? null;
function channelOf(record: { channel?: string | null } | null | undefined) {
return parsePluginChannel(record?.channel) ?? "stable";
}
function addedCapabilities(
@@ -220,6 +224,7 @@ export interface RegistryListing {
features: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
@@ -227,7 +232,11 @@ export interface RegistryListing {
notes?: ReleaseNotes;
size: number;
}>;
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest compatible beta, when one is newer than stable. */
latestBeta: string | null;
channel: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
@@ -259,7 +268,10 @@ export async function listRegistry(
plugins: index.plugins.map((plugin) => {
const record = records.get(plugin.id);
const live = loader.get(plugin.id);
const latest = latestCompatible(plugin.versions);
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -278,8 +290,9 @@ export async function listRegistry(
icon: plugin.icon,
videoId: plugin.videoId,
features: plugin.features,
versions: plugin.versions.map((entry) => ({
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
@@ -288,6 +301,8 @@ export async function listRegistry(
size: entry.size,
})),
latestVersion: latest?.version ?? null,
latestBeta: latestBeta?.version ?? null,
channel,
installed: Boolean(record),
installedVersion: record ? installedVersion : null,
updateAvailable: Boolean(record) && updateAvailable,
@@ -501,7 +516,12 @@ function dependencyChain(pluginId: string): {
plugin.manifest.dependencies ?? {},
)) {
const found = loader.get(dependency);
if (found && !semver.satisfies(found.manifest.version, range)) {
if (
found &&
!semver.satisfies(found.manifest.version, range, {
includePrerelease: true,
})
) {
missing.push(`${dependency}@${range}`);
continue;
}
@@ -604,6 +624,8 @@ export async function setPluginStateUnlocked(
export interface InstallOptions {
version?: string;
/** Install only: the channel to follow from the start. */
channel?: PluginChannel;
userId: string | null;
acceptCapabilities?: boolean;
/**
@@ -637,6 +659,7 @@ function assertConsent(
async function requireRelease(
pluginId: string,
version: string | undefined,
channel: PluginChannel,
): Promise<{
index: RegistryIndex;
release: RegistryVersion;
@@ -654,7 +677,7 @@ async function requireRelease(
"REGISTRY_UNREACHABLE",
);
}
const found = findRelease(index, pluginId, version);
const found = findRelease(index, pluginId, version, channel);
if (!found) {
throw new PluginManageError(
version
@@ -674,7 +697,7 @@ async function requireRelease(
return {
index,
release: found.release,
latest: latestCompatible(found.plugin.versions),
latest: latestOnChannel(found.plugin, channel),
};
}
@@ -711,7 +734,12 @@ export function installPlugin(
);
}
const { release, latest } = await requireRelease(pluginId, options.version);
const channel = options.channel ?? "stable";
const { release, latest } = await requireRelease(
pluginId,
options.version,
channel,
);
assertConsent(options.capabilities, release.capabilities);
const pin = pinFor(release, latest, options.version);
await applyPin(pluginId, pin);
@@ -732,6 +760,7 @@ export function installPlugin(
registryId: OFFICIAL_REGISTRY_ID,
tier: plugin.source === "bundled" ? "bundled" : "official",
pinnedVersion: pin,
channel,
});
await grantDeclared(plugin, options.userId);
@@ -775,7 +804,11 @@ async function updateUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const { release, latest } = await requireRelease(pluginId, options.version);
const { release, latest } = await requireRelease(
pluginId,
options.version,
channelOf(record),
);
if (release.version === live.manifest.version) {
throw new PluginManageError(
`${pluginId} ${release.version} is already installed`,
@@ -951,8 +984,12 @@ export async function uninstallPluginUnlocked(
export function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -970,15 +1007,37 @@ export function setPluginOptions(
const updated = await repository.update(pluginId, {
autoUpdate: options.autoUpdate,
pinnedVersion,
channel: options.channel,
});
await applyPin(pluginId, pinnedVersion);
return {
autoUpdate: Boolean(updated?.autoUpdate),
pinnedVersion: updated?.pinnedVersion ?? null,
channel: channelOf(updated),
};
});
}
/**
* Moves every installed plugin to one channel. Going back to stable never
* downgrades: a plugin on a beta stays there until a stable release passes it.
*/
export function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const changed: string[] = [];
for (const record of await repository.listAll()) {
if (channelOf(record) === channel) continue;
await repository.update(record.id, { channel });
changed.push(record.id);
}
return { changed };
});
}
const MAX_CHANGELOG_BYTES = 512 * 1024;
/** A registry version's Markdown notes, parsed. Undefined when empty. */
+53 -8
View File
@@ -46,6 +46,14 @@ export interface RegistryVersion {
/** This version's CHANGELOG.md section, as Markdown. */
notes?: string;
publishedAt?: string;
/** Listed under the plugin's prereleases: a beta. */
prerelease?: boolean;
}
export type PluginChannel = "stable" | "beta";
export function parsePluginChannel(value: unknown): PluginChannel | null {
return value === "stable" || value === "beta" ? value : null;
}
export interface RegistryPlugin {
@@ -59,8 +67,13 @@ export interface RegistryPlugin {
/** Only ever a YouTube video id, never a link. */
videoId?: string;
features: string[];
/** Newest first. */
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
* Betas newer than the newest stable release, newest first. A separate
* list so cores from before channels never offer one.
*/
prereleases: RegistryVersion[];
}
export interface RegistryIndex {
@@ -126,11 +139,18 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
const entry = item as Record<string, unknown>;
const id = asString(entry.id);
if (!id || !ID_PATTERN.test(id)) continue;
const versions = (Array.isArray(entry.versions) ? entry.versions : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
if (versions.length === 0) continue;
const parseList = (list: unknown) =>
(Array.isArray(list) ? list : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
.filter((v) => semver.prerelease(v.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
id,
name: asString(entry.name) ?? id,
@@ -142,6 +162,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
versions,
prereleases,
});
}
@@ -315,16 +336,40 @@ export function resetRegistryCache(): void {
lastError = null;
}
/** Every release a channel may install, newest first. */
export function channelReleases(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion[] {
if (channel === "stable") return plugin.versions;
return [...plugin.prereleases, ...plugin.versions].sort((a, b) =>
semver.rcompare(a.version, b.version),
);
}
/** The newest release on a channel this build can run. */
export function latestOnChannel(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
);
}
export function findRelease(
index: RegistryIndex,
pluginId: string,
version?: string,
channel: PluginChannel = "stable",
): { plugin: RegistryPlugin; release: RegistryVersion } | null {
const plugin = index.plugins.find((entry) => entry.id === pluginId);
if (!plugin) return null;
const release = version
? plugin.versions.find((entry) => entry.version === version)
: plugin.versions.find((entry) => isApiCompatible(entry.api));
? channelReleases(plugin, "beta").find((entry) => entry.version === version)
: latestOnChannel(plugin, channel);
if (!release) return null;
return { plugin, release };
}
+14 -3
View File
@@ -229,7 +229,12 @@ export function getServiceImplementation<T extends object>(
): T | undefined {
const registration = lookup(service, name);
if (!registration) return undefined;
if (!semver.satisfies(registration.version, versionRange)) return undefined;
if (
!semver.satisfies(registration.version, versionRange, {
includePrerelease: true,
})
)
return undefined;
return registration.implementation as T;
}
@@ -267,7 +272,9 @@ export function resolveRequirements(
const providers = [...(services.get(requirement.service)?.values() ?? [])];
const registration =
providers.find((candidate) =>
semver.satisfies(candidate.version, requirement.versionRange),
semver.satisfies(candidate.version, requirement.versionRange, {
includePrerelease: true,
}),
) ?? providers[0];
if (!registration) {
@@ -280,7 +287,11 @@ export function resolveRequirements(
continue;
}
if (!semver.satisfies(registration.version, requirement.versionRange)) {
if (
!semver.satisfies(registration.version, requirement.versionRange, {
includePrerelease: true,
})
) {
recordUnsatisfied(
requirement,
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
getPluginChangelog: vi.fn(),
deletePluginData: vi.fn(),
@@ -730,6 +731,7 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
channel: "stable",
userId: "admin-1",
capabilities: ["kv:own"],
});
@@ -741,6 +743,68 @@ describe("plugins route", () => {
);
});
it("installs on the beta channel when asked", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.1.0-beta.1",
state: "active",
});
const res = await post("/plugins/docker/install", {
channel: "beta",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"docker",
expect.objectContaining({ channel: "beta" }),
);
expect(
(
await post("/plugins/docker/install", {
channel: "nightly",
capabilities: [],
})
).status,
).toBe(400);
});
it("moves every plugin to one channel", async () => {
manageMock.setAllPluginChannels.mockResolvedValue({
changed: ["docker"],
});
const res = await post("/plugins/channel", { channel: "beta" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ changed: ["docker"] });
expect(manageMock.setAllPluginChannels).toHaveBeenCalledWith("beta");
expect((await post("/plugins/channel", { channel: "x" })).status).toBe(
400,
);
});
it("saves a plugin's channel and refuses an unknown one", async () => {
manageMock.setPluginOptions.mockResolvedValue({
autoUpdate: false,
pinnedVersion: null,
channel: "beta",
});
const patch = (body: unknown) =>
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "admin-1",
},
body: JSON.stringify(body),
});
expect((await patch({ channel: "beta" })).status).toBe(200);
expect(manageMock.setPluginOptions).toHaveBeenCalledWith("docker", {
autoUpdate: undefined,
pinned: undefined,
channel: "beta",
});
expect((await patch({ channel: "nightly" })).status).toBe(400);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
@@ -865,6 +929,7 @@ describe("plugins route", () => {
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
post("/plugins/channel", { channel: "beta" }, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
@@ -21,7 +21,10 @@ const route = ast.statements.find(
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression).arguments[2];
async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
async function invoke(
fetchLatestRelease: ReturnType<typeof vi.fn>,
channel = "stable",
) {
const handler = runInNewContext(
ts.transpileModule(`(${callback.getText(ast)})`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
@@ -29,10 +32,9 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
{
getLocalVersion: () => "2.7.1",
databaseLogger: { warn: vi.fn(), error: vi.fn() },
fetchGitHubAPI,
REPO_OWNER: "Termix-SSH",
REPO_NAME: "Termix",
compareSemver: () => -1,
fetchLatestRelease,
getCoreChannel: async () => channel,
updateStatus: () => "requires_update",
},
);
const res = { json: vi.fn(), status: vi.fn(), send: vi.fn() };
@@ -50,23 +52,33 @@ it("keeps the installed version when the update service is unreachable", async (
expect(res.status).not.toHaveBeenCalled();
});
it("keeps the installed version if the release has no parseable version", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "nightly" } }),
);
const res = await invoke(vi.fn().mockResolvedValue(null));
expect(res.json).toHaveBeenCalledWith({
localVersion: "2.7.1",
status: "unknown",
channel: "stable",
});
});
it("still reports a newer release when the check succeeds", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "2.8.0" } }),
vi.fn().mockResolvedValue({ version: "2.8.0", tagName: "v2.8.0" }),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
localVersion: "2.7.1",
status: "requires_update",
remoteVersion: "2.8.0",
channel: "stable",
}),
);
});
it("checks the channel the instance is on", async () => {
const fetchLatest = vi
.fn()
.mockResolvedValue({ version: "2.8.0-beta.1", tagName: "v2.8.0-beta.1" });
const res = await invoke(fetchLatest, "beta");
expect(fetchLatest).toHaveBeenCalledWith("beta");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ channel: "beta", remoteVersion: "2.8.0-beta.1" }),
);
});
+120 -9
View File
@@ -160,19 +160,22 @@ const DEFAULT_CAPS = ["hosts:read", "kv:own"];
function registryEntry(
id: string,
versions: Array<{ version: string; capabilities?: string[] }>,
prereleases: Array<{ version: string; capabilities?: string[] }> = [],
) {
const toEntry = (v: { version: string; capabilities?: string[] }) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
});
return {
id,
name: id,
versions: versions.map((v) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
})),
versions: versions.map(toEntry),
prereleases: prereleases.map(toEntry),
};
}
@@ -407,6 +410,114 @@ describe("updates", () => {
});
});
describe("beta channel", () => {
it("offers betas only to a plugin on the beta channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
latestVersion: "1.0.0",
latestBeta: "1.1.0-beta.1",
updateAvailable: false,
});
expect(
(await manage.listRegistry()).plugins[0].versions.map((v) => [
v.version,
v.prerelease,
]),
).toEqual([
["1.1.0-beta.1", true],
["1.0.0", false],
]);
await manage.setPluginOptions("docker", { channel: "beta" });
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "beta",
latestVersion: "1.1.0-beta.1",
updateAvailable: true,
});
});
it("keeps an installed beta after going back to stable", async () => {
createFixturePlugin({
id: "docker",
root: bundled,
manifestOverrides: { version: "1.1.0-beta.1" },
});
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
installedVersion: "1.1.0-beta.1",
updateAvailable: false,
});
const result = await manage.updateAllPlugins({ userId: "admin" });
expect(result.updated).toEqual([]);
expect(registry.download).not.toHaveBeenCalled();
registry.index.plugins = [
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
];
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
latestVersion: "1.1.0",
updateAvailable: true,
});
});
it("installs the newest beta when asked to follow betas", async () => {
registry.index.plugins = [
registryEntry(
"notes",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.2" }],
),
];
registry.download.mockImplementation(() =>
stageArtifact("notes", { version: "1.1.0-beta.2" }),
);
await boot();
const result = await manage.installPlugin("notes", {
userId: "admin",
channel: "beta",
capabilities: DEFAULT_CAPS,
});
expect(result.version).toBe("1.1.0-beta.2");
expect(db.rows.get("notes")).toMatchObject({
channel: "beta",
pinnedVersion: null,
});
});
it("moves every installed plugin to one channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
createFixturePlugin({ id: "notes", root: bundled });
await boot();
expect((await manage.setAllPluginChannels("beta")).changed.sort()).toEqual([
"docker",
"notes",
]);
expect(db.rows.get("docker")?.channel).toBe("beta");
expect((await manage.setAllPluginChannels("beta")).changed).toEqual([]);
});
});
describe("dependencies", () => {
it("starts a dependency first and stops dependents first", async () => {
createFixturePlugin({ id: "base", root: bundled });
@@ -29,6 +29,7 @@ import {
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
latestOnChannel,
getStatsUrl,
isApiCompatible,
parseRegistryIndex,
@@ -188,6 +189,63 @@ describe("findRelease", () => {
});
});
describe("prereleases", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [
version({ version: "1.1.0" }),
version({ version: "1.2.0-beta.1" }),
],
prereleases: [
version({ version: "1.2.0-beta.1" }),
version({ version: "1.2.0-beta.10" }),
version({ version: "1.3.0" }),
],
},
{ id: "only-beta", prereleases: [version({ version: "0.1.0-beta.1" })] },
],
});
const docker = index.plugins[0];
it("keeps betas out of the stable list and stable out of betas", () => {
expect(docker.versions.map((v) => v.version)).toEqual(["1.1.0"]);
expect(docker.prereleases.map((v) => v.version)).toEqual([
"1.2.0-beta.10",
"1.2.0-beta.1",
]);
expect(docker.prereleases.every((v) => v.prerelease)).toBe(true);
expect(index.plugins[1].versions).toEqual([]);
});
it("picks by channel and finds a beta by exact version", () => {
expect(latestOnChannel(docker, "stable")?.version).toBe("1.1.0");
expect(latestOnChannel(docker, "beta")?.version).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
expect(
findRelease(index, "docker", undefined, "beta")?.release.version,
).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker", "1.2.0-beta.1")?.release.version).toBe(
"1.2.0-beta.1",
);
expect(findRelease(index, "only-beta")).toBeNull();
});
it("prefers a newer stable over an older beta", () => {
const later = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [version({ version: "1.2.0" })],
prereleases: [version({ version: "1.2.0-beta.3" })],
},
],
});
expect(latestOnChannel(later.plugins[0], "beta")?.version).toBe("1.2.0");
});
});
describe("fetchRegistryIndex", () => {
const body = JSON.stringify({
plugins: [{ id: "docker", versions: [version()] }],
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {}));
const latest = vi.hoisted(() => ({
value: null as null | { version: string; url: string },
value: null as null | { version: string; url: string; prerelease?: boolean },
}));
const channel = vi.hoisted(() => ({ value: "stable", asked: [] as string[] }));
const version = vi.hoisted(() => ({ value: "2.9.0" as string | null }));
const disabled = vi.hoisted(() => ({ value: false }));
@@ -13,7 +14,13 @@ vi.mock("../../utils/app-version.js", () => ({
}));
vi.mock("../../utils/latest-release.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/latest-release.js")>()),
fetchLatestRelease: async () => latest.value,
fetchLatestRelease: async (wanted: string) => {
channel.asked.push(wanted);
return latest.value;
},
}));
vi.mock("../../updates/update-channel.js", () => ({
getCoreChannel: async () => channel.value,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
@@ -31,6 +38,8 @@ beforeEach(() => {
sendCoreAlert.mockClear();
version.value = "2.9.0";
disabled.value = false;
channel.value = "stable";
channel.asked = [];
latest.value = {
version: "2.9.1",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1",
@@ -60,4 +69,29 @@ describe("desktop update check", () => {
await checkForDesktopUpdate();
expect(sendCoreAlert).not.toHaveBeenCalled();
});
it("follows the beta channel", async () => {
channel.value = "beta";
latest.value = {
version: "2.9.1-beta.2",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1-beta.2",
prerelease: true,
};
await checkForDesktopUpdate();
expect(channel.asked).toEqual(["beta"]);
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({
title: "Termix 2.9.1-beta.2 is available",
dedupeKey: "update:2.9.1-beta.2",
}),
);
});
it("does not offer the matching stable as older than its own beta", async () => {
version.value = "2.9.1-beta.2";
await checkForDesktopUpdate();
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({ dedupeKey: "update:2.9.1" }),
);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
compareSemver,
isPrereleaseVersion,
normalizeVersion,
updateStatus,
} from "../../utils/latest-release.js";
describe("normalizeVersion", () => {
it("reads tags and short versions and keeps the prerelease part", () => {
expect(normalizeVersion("v2.8")).toBe("2.8.0");
expect(normalizeVersion("release-2.9.1-tag")).toBe("2.9.1-tag");
expect(normalizeVersion("v26.11.0-beta.2")).toBe("26.11.0-beta.2");
expect(normalizeVersion("nightly")).toBeNull();
});
});
describe("compareSemver", () => {
it("orders betas below their stable release and by number", () => {
expect(compareSemver("26.11.0-beta.1", "26.11.0")).toBe(-1);
expect(compareSemver("26.11.0-beta.2", "26.11.0-beta.10")).toBe(-1);
expect(compareSemver("26.11.0-beta.1", "26.10.3")).toBe(1);
expect(compareSemver("v2.9.0", "2.9")).toBe(0);
expect(compareSemver("x", "2.9.0")).toBeNull();
});
});
describe("updateStatus", () => {
it("calls a newer release on the channel an update", () => {
expect(updateStatus("26.10.0", "26.10.1")).toBe("requires_update");
expect(updateStatus("26.11.0-beta.1", "26.11.0-beta.2")).toBe(
"requires_update",
);
expect(updateStatus("26.11.0-beta.2", "26.11.0")).toBe("requires_update");
});
it("marks betas and builds ahead of the newest release as beta", () => {
expect(updateStatus("26.11.0-beta.2", "26.11.0-beta.2")).toBe("beta");
expect(updateStatus("26.11.0-beta.2", "26.10.0")).toBe("beta");
expect(updateStatus("26.11.0", "26.10.0")).toBe("beta");
expect(updateStatus("26.10.0", "26.10.0")).toBe("up_to_date");
expect(updateStatus("26.10.0", null)).toBe("up_to_date");
});
it("knows a prerelease", () => {
expect(isPrereleaseVersion("26.11.0-beta.1")).toBe(true);
expect(isPrereleaseVersion("26.11.0")).toBe(false);
});
});
+2 -1
View File
@@ -6,6 +6,7 @@
import { sendCoreAlert } from "../notify/core-notify.js";
import { getLocalVersion } from "../utils/app-version.js";
import { compareSemver, fetchLatestRelease } from "../utils/latest-release.js";
import { getCoreChannel } from "./update-channel.js";
import { systemLogger } from "../utils/logger.js";
import {
createCurrentUserPreferenceRepository,
@@ -33,7 +34,7 @@ export async function checkForDesktopUpdate(): Promise<void> {
const current = getLocalVersion();
if (!current || (await checkDisabled())) return;
const latest = await fetchLatestRelease();
const latest = await fetchLatestRelease(await getCoreChannel(current));
if (!latest) return;
const comparison = compareSemver(current, latest.version);
if (comparison === null || comparison >= 0) return;
+34
View File
@@ -0,0 +1,34 @@
/**
* Which Termix releases this instance hears about. The server can't swap its
* own image or installer, so the channel only steers update checks and the
* Updates page. A beta build always counts as beta.
*/
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
isPrereleaseVersion,
type UpdateChannel,
} from "../utils/latest-release.js";
export const CORE_CHANNEL_KEY = "core_update_channel";
export function parseChannel(value: unknown): UpdateChannel | null {
return value === "stable" || value === "beta" ? value : null;
}
export async function getStoredCoreChannel(): Promise<UpdateChannel> {
const stored = await createCurrentSettingsRepository().get(CORE_CHANNEL_KEY);
return parseChannel(stored) ?? "stable";
}
export async function getCoreChannel(
localVersion: string | null = getLocalVersion(),
): Promise<UpdateChannel> {
if (localVersion && isPrereleaseVersion(localVersion)) return "beta";
return getStoredCoreChannel();
}
export async function setCoreChannel(channel: UpdateChannel): Promise<void> {
await createCurrentSettingsRepository().set(CORE_CHANNEL_KEY, channel);
}
+74 -26
View File
@@ -1,3 +1,4 @@
import semver from "semver";
import { databaseLogger } from "./logger.js";
import { getProxyAgent } from "./proxy-agent.js";
import type {
@@ -36,29 +37,32 @@ class GitHubCache {
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
/**
* A release tag or version as full semver, keeping any prerelease part:
* "v2.8" is 2.8.0 and "v26.11.0-beta.2" stays a prerelease.
*/
export function normalizeVersion(version: string | undefined): string | null {
const match = String(version || "").match(
/(\d+)\.(\d+)(?:\.(\d+))?(-[0-9A-Za-z.-]+)?/,
);
if (!match) return null;
const normalized = `${match[1]}.${match[2]}.${match[3] || 0}${match[4] || ""}`;
return semver.valid(normalized);
}
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
export function isPrereleaseVersion(version: string | undefined): boolean {
const normalized = normalizeVersion(version);
return Boolean(normalized && semver.prerelease(normalized));
}
export function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
const parsedA = normalizeVersion(a);
const parsedB = normalizeVersion(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
return semver.compare(parsedA, parsedB);
}
const GITHUB_API_BASE = "https://api.github.com";
@@ -116,28 +120,72 @@ export async function fetchGitHubAPI<T>(
}
}
export type UpdateChannel = "stable" | "beta";
export interface LatestRelease {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
/** The newest Termix release on GitHub, cached for 30 minutes. */
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
const release = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release",
);
const rawTag = release.data.tag_name || release.data.name || "";
const version = rawTag.match(/(\d+\.\d+(\.\d+)?)/)?.[1];
function toLatestRelease(release: GitHubRelease): LatestRelease | null {
const version = normalizeVersion(release.tag_name || release.name || "");
if (!version) return null;
return {
version,
tagName: release.data.tag_name,
name: release.data.name,
url: release.data.html_url,
publishedAt: release.data.published_at,
tagName: release.tag_name,
name: release.name,
url: release.html_url,
publishedAt: release.published_at,
prerelease: Boolean(release.prerelease) || !!semver.prerelease(version),
notes: (release.body || "").slice(0, 20_000),
};
}
/**
* The newest Termix release on GitHub, cached for 30 minutes. On the beta
* channel a newer beta wins over the newest stable release.
*/
export async function fetchLatestRelease(
channel: UpdateChannel = "stable",
): Promise<LatestRelease | null> {
if (channel === "stable") {
const release = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release",
);
return toLatestRelease(release.data);
}
const releases = await fetchGitHubAPI<GitHubRelease[]>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?per_page=30`,
"latest_releases",
);
let newest: LatestRelease | null = null;
for (const release of releases.data) {
if (release.draft) continue;
const parsed = toLatestRelease(release);
if (parsed && (!newest || semver.gt(parsed.version, newest.version))) {
newest = parsed;
}
}
return newest;
}
export type UpdateStatus = "up_to_date" | "beta" | "requires_update";
/** How the running version compares with the newest release on its channel. */
export function updateStatus(
localVersion: string,
latestVersion: string | null | undefined,
): UpdateStatus {
const comparison = latestVersion
? compareSemver(localVersion, latestVersion)
: null;
if (comparison !== null && comparison < 0) return "requires_update";
if (isPrereleaseVersion(localVersion) || (comparison ?? 0) > 0) return "beta";
return "up_to_date";
}
+22 -2
View File
@@ -247,6 +247,7 @@ export interface PluginSummary {
registryId?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: PluginChannel;
description?: string;
author?: string;
repository?: string;
@@ -269,8 +270,12 @@ function announcePluginsChanged(): void {
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
}
export type PluginChannel = "stable" | "beta";
export interface RegistryPluginVersion {
version: string;
/** A beta, offered only to plugins on the beta channel. */
prerelease?: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
@@ -290,7 +295,11 @@ export interface RegistryPluginEntry {
videoId?: string;
features?: string[];
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta?: string | null;
channel?: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
@@ -443,8 +452,12 @@ export async function retryPlugin(pluginId: string): Promise<void> {
export async function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/options`,
options,
@@ -452,6 +465,13 @@ export async function setPluginOptions(
return response.data;
}
export async function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
const response = await rbacApi.post("/plugins/channel", { channel });
return response.data;
}
export async function getPluginData(
pluginId: string,
): Promise<PluginDataSummary> {
+41
View File
@@ -42,6 +42,7 @@ export async function getReleasesRSS(
export interface VersionInfo {
status?: "up_to_date" | "requires_update" | "beta" | "unknown";
channel?: "stable" | "beta";
/** Same value as remoteVersion; the endpoint sends both. */
version?: string;
localVersion?: string;
@@ -81,6 +82,46 @@ export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
}
}
export type UpdateChannel = "stable" | "beta";
export interface UpdateChannelState {
channel: UpdateChannel;
/** The admin's choice; a beta build reports beta whatever it is. */
stored: UpdateChannel;
runningBeta: boolean;
}
export async function getUpdateChannel(): Promise<UpdateChannelState> {
const response = await authApi.get("/version/channel");
return response.data;
}
export async function setUpdateChannel(
channel: UpdateChannel,
): Promise<UpdateChannelState> {
const response = await authApi.put("/version/channel", { channel });
return response.data;
}
export interface ReleaseInfo {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
export async function getChannelReleases(): Promise<{
localVersion: string | null;
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
}> {
const response = await authApi.get("/version/releases");
return response.data;
}
// ============================================================================
// DATABASE HEALTH
// ============================================================================
+19 -5
View File
@@ -87,15 +87,29 @@ export function describeEnvironment(
}
/** A bug report on the Termix core repo. */
export function reportCoreIssueUrl(
termixVersion?: string,
template: "bug_report.yml" | "beta_feedback.yml" = "bug_report.yml",
): string {
export function reportCoreIssueUrl(termixVersion?: string): string {
return buildIssueUrl(CORE_REPO_URL, {
template,
template: "bug_report.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
},
});
}
/** Beta feedback on the core repo, listing every plugin running a beta. */
export function reportBetaFeedbackUrl(
termixVersion?: string,
betaPlugins: Array<{ id: string; version: string }> = [],
): string {
return buildIssueUrl(CORE_REPO_URL, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
"beta-plugins": betaPlugins
.map((plugin) => `${plugin.id} ${plugin.version}`)
.join("\n"),
},
});
}
+56 -7
View File
@@ -1688,10 +1688,6 @@
"twoFaOn": "On",
"twoFaOff": "Off",
"versionLabel": "Version",
"betaProgramTitle": "Beta Program",
"betaProgramDescription": "Try upcoming features early with the weekly rolling :beta Docker tag. Unstable, not for production.",
"betaProgramFeedback": "Found a bug? Report it here.",
"betaProgramLearnMore": "Learn More",
"cliTitle": "Termix CLI",
"cliDescription": "Open terminals, run commands and move files from your own shell.",
"cliLearnMore": "Learn More",
@@ -1826,7 +1822,13 @@
"linkedProfileDescription": "This device uses your account on the server and keeps a copy of your data, so it still works offline.",
"manageSync": "Manage sync",
"setUpSync": "Link to a server",
"settingsTabsAndShortcuts": "Tabs and Shortcuts"
"settingsTabsAndShortcuts": "Tabs and Shortcuts",
"onBetaTitle": "You are on a beta",
"onBetaDescription": "Thanks for testing. Tell us what breaks.",
"tryBetaTitle": "Try new features early",
"tryBetaDescription": "Join the beta program in Updates.",
"sendBetaFeedback": "Send beta feedback",
"openUpdates": "Updates"
}
}
},
@@ -2168,7 +2170,22 @@
"reportIssue": "Report an issue",
"reportTo": "Report to {{author}}",
"requestFeature": "Request a feature",
"openSettings": "Open its settings"
"openSettings": "Open its settings",
"beta": {
"badge": "Beta",
"badgeHint": "This is a beta version. It can have bugs.",
"try": "Try beta {{version}}",
"confirmTitle": "Use betas for {{name}}?",
"confirmBody": "Betas get new features and fixes first, but they can have bugs. You can switch back to stable at any time.",
"confirmInstall": "Install {{version}}",
"confirmSwitch": "Use betas",
"leftWhileOnBeta": "{{name}} stays on {{version}} until a stable release is newer.",
"channel": "Beta versions",
"channelHint": "Get new versions early. They can have bugs.",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"versionLabel": "{{version}} (beta)",
"sendFeedback": "Send beta feedback"
}
}
},
"settings": {
@@ -2207,7 +2224,39 @@
"instanceSettings": "Server settings",
"nothingHere": "Nothing to set here.",
"searchPage": "Search this page",
"noPageMatches": "Nothing on this page matches."
"noPageMatches": "Nothing on this page matches.",
"updates": {
"title": "Updates",
"betaProgram": "Beta program",
"betaIntro": "Betas are early builds of the next Termix version. They come out on Tuesdays and Fridays when there is something new to try. They can have bugs, and your feedback is what makes the stable release better.",
"termixBetas": "Termix betas",
"termixBetasHint": "Tell me about new betas, not only stable releases.",
"runningBetaBuild": "You are running a beta build. To leave, switch back to a stable image or installer.",
"howToInstall": "How to get the beta",
"desktopInstall": "Download the beta installer and install it over this one.",
"downloadBeta": "Download {{version}}",
"dockerInstall": "Change your image to the beta tag and restart the container:",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Could not copy",
"learnMore": "Learn more about betas",
"feedback": "Feedback",
"feedbackHint": "Found a bug or have an idea? It goes straight to the developers with your version filled in.",
"sendFeedback": "Send beta feedback",
"releases": "Releases",
"thisVersion": "This version",
"newestStable": "Newest stable",
"newestBeta": "Newest beta",
"noRelease": "None right now",
"pluginBetas": "Plugin betas",
"pluginBetasIntro": "Each plugin can use betas on its own. To try one, open it and press Try beta.",
"openPlugins": "Open Plugins",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"backToStable": "Back to stable",
"allPluginsBeta": "Use betas for all plugins",
"allPluginsStable": "All plugins back to stable",
"saveFailed": "Could not save the change"
}
},
"managerCard": {
"refresh": "Refresh",
+64 -1
View File
@@ -6,6 +6,7 @@ import {
Database,
Download,
ExternalLink,
FlaskConical,
Lightbulb,
Pin,
RotateCcw,
@@ -26,12 +27,15 @@ import {
} from "@/components/select";
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
import {
BetaBadge,
CapabilityRow,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
} from "./plugin-bits";
import {
betaFeedbackUrl,
betaToTry,
describeContributions,
formatBytes,
orderByRisk,
@@ -80,6 +84,10 @@ export function PluginDetail({
import.meta.env.VITE_APP_VERSION || undefined,
);
const featureUrl = requestFeatureUrl(plugin);
const feedbackUrl = plugin.isBeta
? betaFeedbackUrl(plugin, import.meta.env.VITE_APP_VERSION || undefined)
: null;
const tryableBeta = betaToTry(plugin);
return (
<div
@@ -98,6 +106,7 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
<span>v{plugin.version}</span>
@@ -135,6 +144,18 @@ export function PluginDetail({
})}
</Button>
)}
{tryableBeta && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={() => void manager.tryBeta(plugin)}
>
<FlaskConical className="size-3.5" />
{t("plugins.manager.beta.try", { version: tryableBeta })}
</Button>
)}
{plugin.installed ? (
<>
{failed ? (
@@ -299,7 +320,11 @@ export function PluginDetail({
? t("plugins.manager.latestVersion", {
version: v.version,
})
: v.version}
: v.prerelease
? t("plugins.manager.beta.versionLabel", {
version: v.version,
})
: v.version}
</SelectItem>
))}
</SelectContent>
@@ -346,6 +371,30 @@ export function PluginDetail({
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" />
{t("plugins.manager.beta.channel")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{plugin.channel === "stable" && plugin.isBeta
? t("plugins.manager.beta.waitingForStable", {
version: plugin.version,
})
: t("plugins.manager.beta.channelHint")}
</span>
</div>
<FakeSwitch
checked={plugin.channel === "beta"}
disabled={busy || locked}
onChange={(v) =>
void (v
? manager.tryBeta(plugin)
: manager.leaveBeta(plugin))
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
@@ -401,6 +450,20 @@ export function PluginDetail({
</a>
)}
{feedbackUrl && (
<a
href={feedbackUrl}
target="_blank"
rel="noreferrer"
className={LINK_ROW}
>
<FlaskConical className="size-3.5 shrink-0" />
<span className="truncate">
{t("plugins.manager.beta.sendFeedback")}
</span>
</a>
)}
{featureUrl && (
<a
href={featureUrl}
+2
View File
@@ -35,6 +35,7 @@ import {
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import {
BetaBadge,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
@@ -469,6 +470,7 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
{adds.length > 0 && <span>{adds.join(", ")}</span>}
+12
View File
@@ -88,6 +88,18 @@ export function UnverifiedBadge() {
);
}
export function BetaBadge() {
const { t } = useTranslation();
return (
<span
className="shrink-0 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"
title={t("plugins.manager.beta.badgeHint")}
>
{t("plugins.manager.beta.badge")}
</span>
);
}
/** Active installs when the registry has them, release downloads otherwise. */
export function InstallCountFact({
count,
+56
View File
@@ -11,6 +11,7 @@ import {
type ChangelogRelease,
} from "@termix-ssh/plugin-sdk/changelog";
import type {
PluginChannel,
PluginSummary,
PluginUploadPreview,
RegistryPluginEntry,
@@ -38,6 +39,11 @@ export interface PluginEntry {
installed: boolean;
version: string | null;
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta: string | null;
channel: PluginChannel;
/** The installed copy is a beta. */
isBeta: boolean;
updateAvailable: boolean;
addedCapabilities: string[];
versions: RegistryPluginVersion[];
@@ -60,6 +66,26 @@ export interface PluginEntry {
export type PluginSource = "bundled" | "official" | "unverified";
export function isPrerelease(version: string | null | undefined): boolean {
return Boolean(
version && semver.valid(version) && semver.prerelease(version),
);
}
/** A beta newer than what is installed, worth offering with "Try beta". */
export function betaToTry(
plugin: Pick<PluginEntry, "installed" | "version" | "latestBeta" | "channel">,
): string | null {
const { latestBeta, version } = plugin;
if (!plugin.installed || plugin.channel === "beta" || !latestBeta) {
return null;
}
if (!version || !semver.valid(version) || !semver.valid(latestBeta)) {
return null;
}
return semver.gt(latestBeta, version) ? latestBeta : null;
}
/** Where an installed copy came from, as the issue report names it. */
export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.tier === "unverified") return "unverified";
@@ -101,6 +127,9 @@ export function mergePlugins(
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
latestBeta: entry?.latestBeta ?? null,
channel: summary.channel ?? entry?.channel ?? "stable",
isBeta: isPrerelease(summary.version),
updateAvailable: entry?.updateAvailable ?? false,
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
@@ -138,6 +167,9 @@ export function mergePlugins(
installed: false,
version: null,
latestVersion: entry.latestVersion,
latestBeta: entry.latestBeta ?? null,
channel: "stable",
isBeta: false,
updateAvailable: false,
addedCapabilities: [],
versions: entry.versions,
@@ -173,6 +205,9 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
installed: preview.replaces !== null,
version: preview.replaces,
latestVersion: preview.version,
latestBeta: null,
channel: "stable",
isBeta: isPrerelease(preview.replaces),
updateAvailable: false,
addedCapabilities: [],
versions: [],
@@ -358,6 +393,27 @@ ${state}`,
});
}
/**
* Beta feedback on the plugin's own repo, through the org beta form. Null when
* the repository is not on GitHub.
*/
export function betaFeedbackUrl(
plugin: Pick<PluginEntry, "id" | "repository" | "version" | "lastError">,
termixVersion?: string,
): string | null {
const repo = normalizeGitHubRepo(plugin.repository);
if (!repo) return null;
return buildIssueUrl(repo, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
"plugin-version": plugin.version,
environment: describeEnvironment(),
logs: plugin.lastError,
},
});
}
/** A new feature request on the plugin's own repo, or null when not on GitHub. */
export function requestFeatureUrl(
plugin: Pick<PluginEntry, "repository">,
+59 -1
View File
@@ -12,6 +12,7 @@ import {
installUploadedPlugin,
previewPluginState,
retryPlugin,
setAllPluginChannels,
setPluginOptions,
setPluginState,
uninstallPlugin,
@@ -19,6 +20,7 @@ import {
updatePlugin,
uploadPlugin,
type DeveloperModeState,
type PluginChannel,
type PluginSummary,
type RegistryListing,
} from "@/api/plugins-api";
@@ -484,7 +486,11 @@ export function usePluginsManager() {
const setOptions = useCallback(
(
plugin: PluginEntry,
options: { autoUpdate?: boolean; pinned?: boolean },
options: {
autoUpdate?: boolean;
pinned?: boolean;
channel?: PluginChannel;
},
) =>
run(plugin.id, async () => {
try {
@@ -496,6 +502,55 @@ export function usePluginsManager() {
[run, failToast],
);
/** Moves a plugin to betas and installs the newest one. */
const tryBeta = useCallback(
async (plugin: PluginEntry) => {
const version = plugin.latestBeta;
const ok = await confirm({
title: t("plugins.manager.beta.confirmTitle", { name: plugin.name }),
description: t("plugins.manager.beta.confirmBody"),
confirmLabel: version
? t("plugins.manager.beta.confirmInstall", { version })
: t("plugins.manager.beta.confirmSwitch"),
destructive: false,
});
if (!ok) return;
await setOptions(plugin, { channel: "beta", pinned: false });
if (version && version !== plugin.version) {
await requestUpdate({ ...plugin, pinnedVersion: null }, version);
}
},
[confirm, t, setOptions, requestUpdate],
);
/** Back to stable. An installed beta stays until stable passes it. */
const leaveBeta = useCallback(
async (plugin: PluginEntry) => {
await setOptions(plugin, { channel: "stable" });
if (plugin.isBeta) {
toast.info(
t("plugins.manager.beta.leftWhileOnBeta", {
name: plugin.name,
version: plugin.version,
}),
);
}
},
[setOptions, t],
);
const setAllChannels = useCallback(
(channel: PluginChannel) =>
run("*", async () => {
try {
await setAllPluginChannels(channel);
} catch (error) {
failToast(error, "plugins.manager.errors.options", "");
}
}),
[run, failToast],
);
const updateAll = useCallback(
() =>
run("*", async () => {
@@ -545,6 +600,9 @@ export function usePluginsManager() {
uninstall,
removeData,
setOptions,
tryBeta,
leaveBeta,
setAllChannels,
updateAll,
};
}
+2
View File
@@ -29,6 +29,7 @@ import {
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
@@ -376,6 +377,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{pluginFeature && (
<>
{pluginFeature.user && (
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
const BETA_DOCS_URL =
"https://docs.termix.site/install/server/docker#beta-builds";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
export function UpdatesSettings() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={BETA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
+8
View File
@@ -1,6 +1,7 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
@@ -116,6 +117,13 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
admin: true,
adminSection: "general",
}),
core({
id: "updates",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
}),
core({
id: "admin-users",
band: "instance",
+37 -4
View File
@@ -4,10 +4,6 @@
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.betaProgramDescription",
"newUi.sidebar.userProfile.betaProgramFeedback",
"newUi.sidebar.userProfile.betaProgramLearnMore",
"newUi.sidebar.userProfile.betaProgramTitle",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
@@ -19,9 +15,15 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
@@ -431,4 +433,35 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
+13 -1
View File
@@ -13,6 +13,7 @@ import {
import {
Bug,
Clock,
FlaskConical,
Folder,
Globe,
KeyRound,
@@ -43,7 +44,8 @@ import {
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
@@ -234,6 +236,16 @@ export function CommandPalette({
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
...(isPrerelease(import.meta.env.VITE_APP_VERSION)
? [
{
id: "link-beta-feedback",
label: t("newUi.sidebar.userProfile.sendBetaFeedback"),
icon: FlaskConical,
url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION),
},
]
: []),
].filter((link) => matches(link.label));
const rowClass = "group gap-2 rounded-none";
+33 -25
View File
@@ -68,7 +68,8 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import {
FeatureSettingsSection,
featureSectionId,
@@ -464,6 +465,7 @@ export function UserProfilePanel({
const [accountUsername, setAccountUsername] = useState(username ?? "");
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
const [userRole, setUserRole] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
@@ -586,6 +588,7 @@ export function UserProfilePanel({
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setIsAdmin(Boolean(linked ? linked.isAdmin : info.is_admin));
setUserRole(
(linked ? linked.isAdmin : info.is_admin)
? t("newUi.sidebar.userProfile.roleAdministrator")
@@ -1294,37 +1297,42 @@ export function UserProfilePanel({
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.betaProgramTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
{(isPrerelease(version) || isAdmin) && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaTitle")
: t("newUi.sidebar.userProfile.tryBetaTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaDescription")
: t("newUi.sidebar.userProfile.tryBetaDescription")}
</span>
</div>
{isPrerelease(version) ? (
<a
href={reportCoreIssueUrl(
version || undefined,
"beta_feedback.yml",
)}
href={reportBetaFeedbackUrl(version)}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
{t("newUi.sidebar.userProfile.sendBetaFeedback")}
</a>
</span>
) : (
<button
type="button"
onClick={() => onOpenSettingsPage?.("updates")}
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.openUpdates")}
</button>
)}
</div>
<a
href="https://docs.termix.site/install/server/docker#beta-builds"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
</a>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
+24 -2
View File
@@ -3,6 +3,7 @@ import {
buildIssueUrl,
describeEnvironment,
normalizeGitHubRepo,
reportBetaFeedbackUrl,
reportCoreIssueUrl,
} from "@/lib/issue-url";
@@ -87,10 +88,31 @@ describe("describeEnvironment", () => {
describe("reportCoreIssueUrl", () => {
it("points at the core repo with the version filled in", () => {
const url = new URL(reportCoreIssueUrl("26.10.0", "beta_feedback.yml"));
const url = new URL(reportCoreIssueUrl("26.10.0"));
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("template")).toBe("bug_report.yml");
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
expect(url.searchParams.get("environment")).toBeTruthy();
});
});
describe("reportBetaFeedbackUrl", () => {
it("uses the beta form and lists plugins on a beta", () => {
const url = new URL(
reportBetaFeedbackUrl("26.11.0-beta.2", [
{ id: "docker", version: "1.2.0-beta.1" },
{ id: "tunnels", version: "1.1.0-beta.3" },
]),
);
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("termix-version")).toBe("26.11.0-beta.2");
expect(url.searchParams.get("beta-plugins")).toBe(
"docker 1.2.0-beta.1\ntunnels 1.1.0-beta.3",
);
});
it("leaves the plugin list out when none are on a beta", () => {
const url = new URL(reportBetaFeedbackUrl("26.11.0-beta.2"));
expect(url.searchParams.has("beta-plugins")).toBe(false);
});
});
+50
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
import {
betaFeedbackUrl,
betaToTry,
isPrerelease,
categoriesOf,
describeContributions,
formatBytes,
@@ -416,3 +419,50 @@ describe("youtubeEmbedUrl", () => {
}
});
});
describe("betas", () => {
it("knows a prerelease", () => {
expect(isPrerelease("1.2.0-beta.1")).toBe(true);
expect(isPrerelease("1.2.0")).toBe(false);
expect(isPrerelease(null)).toBe(false);
});
it("offers a beta only when newer than what is installed on stable", () => {
const base = {
installed: true,
version: "1.1.0",
latestBeta: "1.2.0-beta.1",
channel: "stable" as const,
};
expect(betaToTry(base)).toBe("1.2.0-beta.1");
expect(betaToTry({ ...base, channel: "beta" })).toBeNull();
expect(betaToTry({ ...base, version: "1.2.0-beta.1" })).toBeNull();
expect(betaToTry({ ...base, installed: false })).toBeNull();
expect(betaToTry({ ...base, latestBeta: null })).toBeNull();
});
it("sends beta feedback to the plugin's repo", () => {
const url = new URL(
betaFeedbackUrl(
{
id: "docker",
repository: "https://github.com/Termix-SSH/Plugin-Docker",
version: "1.2.0-beta.1",
lastError: null,
},
"26.11.0",
)!,
);
expect(url.pathname).toBe("/Termix-SSH/Plugin-Docker/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("plugin-version")).toBe("1.2.0-beta.1");
expect(
betaFeedbackUrl({
id: "x",
repository: "https://gitlab.com/a/b",
version: "1.0.0",
lastError: null,
}),
).toBeNull();
});
});