mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: beta channels for core and plugins with one stable/beta release workflow
This commit is contained in:
1 parent
6a855c0fa8
commit
f73c4b5ad6
55 files changed
+16200
-460
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugins` ADD `channel` text DEFAULT ('stable') NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "plugins" ADD COLUMN "channel" text DEFAULT 'stable' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `plugins` ADD `channel` text DEFAULT 'stable' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user