From 095d1c6fd52da51df192844ecd55c04cb0c42518 Mon Sep 17 00:00:00 2001 From: purian23 Date: Fri, 30 Oct 2026 05:39:22 -0400 Subject: [PATCH] feat(packaging): dual-series migration publishing & dms migrate dux - Further banner notices will show once per/shell session in notifications, dms doctor and software updates - Doc updates to follow Port 2.0 --- .github/scripts/publish-void-r2.sh | 61 ++- .github/workflows/prune-r2.yml | 1 + .github/workflows/run-copr.yml | 198 ++++---- .github/workflows/run-obs.yml | 193 ++++---- .github/workflows/run-ppa.yml | 47 +- .github/workflows/run-xbps.yml | 41 +- core/cmd/dms/commands_common.go | 1 + core/cmd/dms/commands_doctor.go | 31 ++ core/cmd/dms/commands_greeter.go | 11 +- core/cmd/dms/commands_migrate.go | 117 +++++ core/cmd/dms/commands_migrate_test.go | 81 ++++ core/cmd/dms/commands_switch.go | 34 +- core/internal/distros/base.go | 4 +- core/internal/distros/debian.go | 89 +++- core/internal/distros/fedora.go | 123 +++-- core/internal/distros/fedora_test.go | 4 +- core/internal/distros/immutable_test.go | 10 +- core/internal/distros/opensuse.go | 104 +++-- core/internal/distros/repos.go | 257 +++++++++++ core/internal/distros/repos_test.go | 424 ++++++++++++++++++ core/internal/distros/ubuntu.go | 71 ++- core/internal/distros/variant_swap_test.go | 2 +- core/internal/distros/void.go | 116 ++++- core/internal/server/sysupdate/handlers.go | 36 ++ .../server/sysupdate/repo_status_test.go | 61 +++ core/internal/site/site.go | 4 +- distro/fedora/dms.spec | 2 +- distro/scripts/copr-upload.sh | 5 +- distro/scripts/obs-status.sh | 2 +- distro/scripts/obs-upload.sh | 2 +- distro/scripts/ppa-build.sh | 11 +- distro/scripts/ppa-status.sh | 2 +- distro/scripts/ppa-sync-plan.sh | 2 +- distro/scripts/ppa-upload.sh | 24 +- distro/void/README.md | 15 +- quickshell/Common/SessionData.qml | 1 + quickshell/Common/Site.qml | 2 +- quickshell/Common/settings/SessionSpec.js | 3 + .../Modules/Settings/SoftwareUpdatesTab.qml | 22 + quickshell/Services/DMSService.qml | 4 + quickshell/Services/RepoMoveNotice.js | 6 + quickshell/Services/SystemUpdateService.qml | 52 +++ .../tests/unit/tst_sysupdate_repo_notice.qml | 24 + 43 files changed, 1879 insertions(+), 421 deletions(-) create mode 100644 core/cmd/dms/commands_migrate.go create mode 100644 core/cmd/dms/commands_migrate_test.go create mode 100644 core/internal/distros/repos.go create mode 100644 core/internal/distros/repos_test.go create mode 100644 core/internal/server/sysupdate/repo_status_test.go create mode 100644 quickshell/Services/RepoMoveNotice.js create mode 100644 quickshell/tests/unit/tst_sysupdate_repo_notice.qml diff --git a/.github/scripts/publish-void-r2.sh b/.github/scripts/publish-void-r2.sh index bf6489b4a..ecece6adf 100755 --- a/.github/scripts/publish-void-r2.sh +++ b/.github/scripts/publish-void-r2.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash -# Download / publish an XBPS repository prefix on Cloudflare R2. -# Shared by dux-shell and dux-repo (same prefix); keep both copies identical. +# Shared by dux-shell and dux-repo, which both write dms/current; keep both copies identical. # ETag guard: publish aborts if another writer replaced x86_64-repodata mid-run. set -euo pipefail usage() { - echo "usage: $0 download|publish" >&2 + echo "usage: $0 download|is-indexed|index|publish" >&2 exit 2 } @@ -78,6 +77,60 @@ download() { cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/" } +# Staged pkgvers count as indexed too. +indexed_pkgvers() { + local repodata="$REPOSITORY_DIR/current/x86_64-repodata" member + [[ -s "$repodata" ]] || return 0 + for member in index.plist stage.plist; do + tar -xOf "$repodata" "$member" 2>/dev/null | python3 -c ' +import plistlib, sys +data = sys.stdin.buffer.read() +for pkg in (plistlib.loads(data).values() if data.strip() else ()): + print(pkg["pkgver"]) +' || true + done +} + +# Also catches packages uploaded by a run that died before its repodata PUT. +is_indexed() { + local known file + shopt -s nullglob + known="$(indexed_pkgvers)" + for file in "$REPOSITORY_DIR"/current/*.xbps; do + if ! grep -qxF "$(xbps-uhelper binpkgver "$(basename "$file")")" <<<"$known"; then + echo "$(basename "$file") is not in ${R2_PREFIX}/current/x86_64-repodata" + return 1 + fi + done +} + +index() { + require_env XBPS_PRIVATE_KEY + local dir="$REPOSITORY_DIR/current" sig + + shopt -s nullglob + local pkgs=("$dir"/*.xbps) + if (( ${#pkgs[@]} == 0 )); then + echo "error: no .xbps files to index in $dir" >&2 + exit 1 + fi + + rm -f "$dir"/*.sig + xbps-rindex -a "${pkgs[@]}" + xbps-rindex --remove-obsoletes "$dir" + + # Unchanged packages keep their .sig2 byte-for-byte; drop those of removed packages. + for sig in "$dir"/*.sig2; do + [[ -f "${sig%.sig2}" ]] || rm -f "$sig" + done + + XBPS_KEY_FILE="$(mktemp)" + trap 'rm -f "$XBPS_KEY_FILE"' EXIT + printf '%s\n' "$XBPS_PRIVATE_KEY" > "$XBPS_KEY_FILE" + xbps-rindex --sign --signedby "AvengeMedia " --privkey "$XBPS_KEY_FILE" "$dir" + xbps-rindex --sign-pkg --privkey "$XBPS_KEY_FILE" "$dir"/*.xbps +} + build_manifest() { local packages='[]' previous_sources='{}' local file filename pkgver name version revision sha size @@ -226,6 +279,8 @@ publish() { case "${1:-}" in download) download ;; + is-indexed) is_indexed ;; + index) index ;; publish) publish ;; *) usage ;; esac diff --git a/.github/workflows/prune-r2.yml b/.github/workflows/prune-r2.yml index c8aec5f43..4ee018e5d 100644 --- a/.github/workflows/prune-r2.yml +++ b/.github/workflows/prune-r2.yml @@ -3,6 +3,7 @@ name: Prune Void R2 archive # Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by # deleting the oldest archive/// snapshots written by # publish-void-r2.sh. Live repositories (/current/) are never touched. +# comes from the listing, so new and legacy prefixes need no list. on: schedule: diff --git a/.github/workflows/run-copr.yml b/.github/workflows/run-copr.yml index 1cd02dc33..c89029e78 100644 --- a/.github/workflows/run-copr.yml +++ b/.github/workflows/run-copr.yml @@ -18,185 +18,141 @@ on: description: 'Release number (e.g., 1, 2, 3 for hotfixes)' required: false default: '1' + owners: + description: 'Copr owners to publish to' + required: false + default: 'all' + type: choice + options: + - all + - duxlabs + - avengemedia jobs: - determine-packages: + plan: runs-on: ubuntu-latest outputs: - packages: ${{ steps.set-packages.outputs.packages }} + matrix: ${{ steps.plan.outputs.matrix }} + version: ${{ steps.plan.outputs.version }} + release: ${{ steps.plan.outputs.release }} steps: - - name: Set package list - id: set-packages + - name: Plan owners and version + id: plan + env: + OWNERS: ${{ inputs.owners || 'all' }} + PACKAGE: ${{ inputs.package || 'dms' }} + VERSION: ${{ inputs.version }} + RELEASE: ${{ inputs.release || '1' }} run: | - PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}" - echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT + MATRIX=$(jq -cn --arg owners "$OWNERS" --arg pkg "$PACKAGE" ' + [{owner: "duxlabs", suffix: ""}, {owner: "avengemedia", suffix: "_LEGACY"}] + | map(select($owners == "all" or .owner == $owners) | .package = $pkg) + | {include: .}') + + if [ -z "$VERSION" ]; then + VERSION=$(curl -sL "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name' | sed 's/^v//') + fi + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + echo "::error::Could not determine the latest release of ${{ github.repository }}" + exit 1 + fi + + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release=$RELEASE" >> "$GITHUB_OUTPUT" + echo "Building $PACKAGE $VERSION-$RELEASE for: $(jq -r '[.include[].owner] | join(", ")' <<< "$MATRIX")" build-and-upload: - needs: determine-packages + name: ${{ matrix.package }} to ${{ matrix.owner }} + needs: plan runs-on: ubuntu-latest strategy: - matrix: - package: ${{ fromJSON(needs.determine-packages.outputs.packages) }} + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + env: + VERSION: ${{ needs.plan.outputs.version }} + RELEASE: ${{ needs.plan.outputs.release }} + PACKAGE: ${{ matrix.package }} + COPR_PROJECT: ${{ matrix.owner }}/dms steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Determine version - id: version - run: | - # Get version from manual input or latest release - if [ -n "${{ github.event.inputs.version }}" ]; then - VERSION="${{ github.event.inputs.version }}" - echo "Using manual version: $VERSION" - else - VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//') - echo "Using latest release version: $VERSION" - fi - - RELEASE="${{ github.event.inputs.release }}" - if [ -z "$RELEASE" ]; then - RELEASE="1" - fi - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "release=$RELEASE" >> $GITHUB_OUTPUT - echo "✅ Building ${{ matrix.package }} version: $VERSION-$RELEASE" - - name: Setup build environment run: | sudo apt-get update - sudo apt-get install -y rpm wget curl jq gzip + sudo apt-get install -y rpm wget curl jq gzip python3-pip mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - echo "✅ RPM build environment ready" - name: Download release assets run: | - VERSION="${{ steps.version.outputs.version }}" cd ~/rpmbuild/SOURCES - - echo "📦 Downloading DMS QML source for v${VERSION}..." - - # Download DMS QML source - wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || { - echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}" + wget "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/dms-qml.tar.gz" || { + echo "::error::Failed to download dms-qml.tar.gz for v${VERSION}" exit 1 } - - echo "✅ Source downloaded" - echo "Note: dms-cli binary will be downloaded during build based on target architecture" ls -lh - name: Generate stable spec file run: | - VERSION="${{ steps.version.outputs.version }}" - RELEASE="${{ steps.version.outputs.release }}" - CHANGELOG_DATE="$(date '+%a %b %d %Y')" - PACKAGE="${{ matrix.package }}" - - # Copy spec file from repository - cp distro/fedora/${PACKAGE}.spec ~/rpmbuild/SPECS/${PACKAGE}.spec - - # Replace placeholders with actual values - sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec - sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec - sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec - - echo "✅ Spec file generated for ${PACKAGE} v${VERSION}-${RELEASE}" - echo "" - echo "=== Spec file preview ===" - head -40 ~/rpmbuild/SPECS/${PACKAGE}.spec + SPEC=~/rpmbuild/SPECS/${PACKAGE}.spec + cp "distro/fedora/${PACKAGE}.spec" "$SPEC" + sed -i -e "s/VERSION_PLACEHOLDER/${VERSION}/g" \ + -e "s/RELEASE_PLACEHOLDER/${RELEASE}/g" \ + -e "s/CHANGELOG_DATE_PLACEHOLDER/$(date '+%a %b %d %Y')/g" "$SPEC" + head -40 "$SPEC" - name: Build SRPM id: build run: | - cd ~/rpmbuild/SPECS - PACKAGE="${{ matrix.package }}" - - echo "🔨 Building SRPM for ${PACKAGE}..." - rpmbuild -bs ${PACKAGE}.spec - + rpmbuild -bs ~/rpmbuild/SPECS/${PACKAGE}.spec SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1) - SRPM_NAME=$(basename "$SRPM") - - echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT - echo "srpm_name=$SRPM_NAME" >> $GITHUB_OUTPUT - - echo "✅ SRPM built: $SRPM_NAME" - echo "" - echo "=== SRPM Info ===" + echo "srpm_path=$SRPM" >> "$GITHUB_OUTPUT" + echo "srpm_name=$(basename "$SRPM")" >> "$GITHUB_OUTPUT" rpm -qpi "$SRPM" + # Per-owner name: two legs uploading the same artifact name get a 409. - name: Upload SRPM artifact uses: actions/upload-artifact@v5 with: - name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }} + name: ${{ matrix.package }}-stable-srpm-${{ needs.plan.outputs.version }}-${{ matrix.owner }} path: ${{ steps.build.outputs.srpm_path }} retention-days: 90 - name: Install Copr CLI + env: + COPR_LOGIN: ${{ secrets[format('COPR_LOGIN{0}', matrix.suffix)] }} + COPR_TOKEN: ${{ secrets[format('COPR_TOKEN{0}', matrix.suffix)] }} run: | - sudo apt-get install -y python3-pip + : "${COPR_LOGIN:?COPR_LOGIN${{ matrix.suffix }} is not set}" "${COPR_TOKEN:?COPR_TOKEN${{ matrix.suffix }} is not set}" pip3 install copr-cli - mkdir -p ~/.config - cat > ~/.config/copr << EOF - [copr-cli] - login = ${{ secrets.COPR_LOGIN }} - username = avengemedia - token = ${{ secrets.COPR_TOKEN }} - copr_url = https://copr.fedorainfracloud.org - EOF - chmod 600 ~/.config/copr - - echo "✅ Copr CLI configured" - - - name: Determine Copr project - id: copr_project - run: | - PACKAGE="${{ matrix.package }}" - if [ "$PACKAGE" = "dms" ]; then - COPR_PROJECT="avengemedia/dms" - else - echo "❌ Unknown package: $PACKAGE" - exit 1 - fi - echo "copr_project=$COPR_PROJECT" >> $GITHUB_OUTPUT - echo "✅ Copr project: $COPR_PROJECT" + umask 077 + printf '[copr-cli]\nlogin = %s\nusername = %s\ntoken = %s\ncopr_url = https://copr.fedorainfracloud.org\n' \ + "$COPR_LOGIN" "${{ matrix.owner }}" "$COPR_TOKEN" > ~/.config/copr - name: Upload to Copr run: | SRPM="${{ steps.build.outputs.srpm_path }}" - VERSION="${{ steps.version.outputs.version }}" - COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}" - PACKAGE="${{ matrix.package }}" - - echo "🚀 Uploading ${PACKAGE} SRPM to ${COPR_PROJECT}..." - echo " SRPM: $(basename $SRPM)" - echo " Version: $VERSION" - + echo "Uploading $(basename "$SRPM") to ${COPR_PROJECT}" BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1) echo "$BUILD_OUTPUT" BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown") - if [ "$BUILD_ID" != "unknown" ]; then - echo "✅ Build submitted successfully!" - echo "🔗 https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/" + echo "https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/" else - echo "⚠️ Could not extract build ID, but upload may have succeeded" + echo "::warning::Could not extract build ID, but upload may have succeeded" fi - name: Build summary if: always() run: | - PACKAGE="${{ matrix.package }}" - COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}" - echo "### 🎉 ${PACKAGE} Stable Build Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Package:** ${PACKAGE}" >> $GITHUB_STEP_SUMMARY - echo "- **Version:** ${{ steps.version.outputs.version }}-${{ steps.version.outputs.release }}" >> $GITHUB_STEP_SUMMARY - echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" >> $GITHUB_STEP_SUMMARY - echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Stable release has been built and uploaded to Copr!" >> $GITHUB_STEP_SUMMARY + { + echo "### ${PACKAGE} stable to ${{ matrix.owner }}" + echo "" + echo "- **Version:** ${VERSION}-${RELEASE}" + echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" + echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/run-obs.yml b/.github/workflows/run-obs.yml index d12027543..29003d13d 100644 --- a/.github/workflows/run-obs.yml +++ b/.github/workflows/run-obs.yml @@ -16,6 +16,15 @@ on: description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)" required: false default: "" + owners: + description: "OBS owners to publish to" + required: false + default: "all" + type: choice + options: + - all + - duxlabs + - avengemedia schedule: - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) @@ -27,7 +36,7 @@ jobs: outputs: has_updates: ${{ steps.check.outputs.has_updates }} - packages: ${{ steps.check.outputs.packages }} + matrix: ${{ steps.check.outputs.matrix }} version: ${{ steps.check.outputs.version }} steps: @@ -40,13 +49,13 @@ jobs: - name: Check for updates id: check env: - OBS_USERNAME: ${{ secrets.OBS_USERNAME }} - OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} + # duxlabs is dispatch-only until its OBS account exists; drop this gate then + OWNERS: ${{ github.event_name == 'workflow_dispatch' && inputs.owners || 'avengemedia' }} run: | - # Helper function to check dms-git commit + # The public source API needs no credentials, so one job checks every owner check_dms_git() { local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD) - local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "") + local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "") local OBS_COMMIT=$(echo "$OBS_SPEC" | grep "^Version:" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "") if [[ -n "$OBS_COMMIT" && "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then @@ -58,14 +67,13 @@ jobs: fi } - # Helper function to check dms stable tag # Sets LATEST_TAG variable in parent scope if update needed check_dms_stable() { - LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") - local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "") + LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") + local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms/dms/dms.spec" 2>/dev/null || echo "") local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "") - if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$OBS_VERSION" ]]; then + if [[ -n "$LATEST_TAG" && "${LATEST_TAG#v}" == "$OBS_VERSION" ]]; then echo "📋 dms: Tag $LATEST_TAG already exists, skipping" return 1 # No update needed else @@ -74,94 +82,85 @@ jobs: fi } - # Main logic REBUILD="${{ github.event.inputs.rebuild_release }}" + PKG="${{ github.event.inputs.package }}" + VERSION="" - if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then - # Run from tag with no package specified - update stable package - echo "packages=dms" >> $GITHUB_OUTPUT - VERSION="${GITHUB_REF#refs/tags/}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - echo "Triggered by tag: $VERSION (always update)" + # Returns via OWNER_PACKAGES, and VERSION when a stable tag is due + plan_owner() { + OWNER_PACKAGES="" + if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "$PKG" ]]; then + OWNER_PACKAGES="dms" + VERSION="${GITHUB_REF#refs/tags/}" + echo "Triggered by tag: $VERSION (always update)" - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - # Scheduled run - check dms-git only - echo "packages=dms-git" >> $GITHUB_OUTPUT - if check_dms_git; then - echo "has_updates=true" >> $GITHUB_OUTPUT - else - echo "has_updates=false" >> $GITHUB_OUTPUT - fi + elif [[ "${{ github.event_name }}" == "schedule" ]]; then + if check_dms_git "$1"; then + OWNER_PACKAGES="dms-git" + fi - elif [[ -n "${{ github.event.inputs.package }}" ]]; then - # Manual workflow trigger - PKG="${{ github.event.inputs.package }}" + elif [[ -n "$PKG" ]]; then + if [[ -n "$REBUILD" ]]; then + OWNER_PACKAGES="$PKG" + echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)" - if [[ -n "$REBUILD" ]]; then - # Rebuild requested - always proceed - echo "packages=$PKG" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)" - - elif [[ "$PKG" == "all" ]]; then - # Check the stable package and build list of those needing updates - PACKAGES_TO_UPDATE=() - if check_dms_stable; then - PACKAGES_TO_UPDATE+=("dms") - if [[ -n "$LATEST_TAG" ]]; then - echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT + elif [[ "$PKG" == "all" || "$PKG" == "dms" ]]; then + # "all" only checks the stable package + if check_dms_stable "$1"; then + OWNER_PACKAGES="dms" + if [[ -n "$LATEST_TAG" ]]; then + VERSION="$LATEST_TAG" + fi fi - fi - if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then - echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}" - else - echo "packages=" >> $GITHUB_OUTPUT - echo "has_updates=false" >> $GITHUB_OUTPUT - echo "✓ All packages up to date" - fi - - elif [[ "$PKG" == "dms-git" ]]; then - if check_dms_git; then - echo "packages=$PKG" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - else - echo "packages=" >> $GITHUB_OUTPUT - echo "has_updates=false" >> $GITHUB_OUTPUT - fi - - elif [[ "$PKG" == "dms" ]]; then - if check_dms_stable; then - echo "packages=$PKG" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - if [[ -n "$LATEST_TAG" ]]; then - echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT + elif [[ "$PKG" == "dms-git" ]]; then + if check_dms_git "$1"; then + OWNER_PACKAGES="$PKG" fi - else - echo "packages=" >> $GITHUB_OUTPUT - echo "has_updates=false" >> $GITHUB_OUTPUT - fi + else + OWNER_PACKAGES="$PKG" + echo "Manual trigger: $PKG" + fi else - # Unknown package - proceed anyway - echo "packages=$PKG" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT - echo "Manual trigger: $PKG" + OWNER_PACKAGES="all" fi + } + + # Planned per owner: the new projects may lag the legacy ones + INCLUDE='[]' + for T in duxlabs,,home:duxlabs avengemedia,_LEGACY,home:AvengeMedia; do + IFS=, read -r OWNER SUFFIX PROJECT <<< "$T" + [[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue + echo "==> $OWNER ($PROJECT)" + plan_owner "$PROJECT" + if [[ -z "$OWNER_PACKAGES" ]]; then + echo "✓ $OWNER: all packages up to date" + continue + fi + echo "✓ $OWNER: packages to update: $OWNER_PACKAGES" + INCLUDE=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --arg p "$PROJECT" --arg k "$OWNER_PACKAGES" \ + '. + [{owner: $o, suffix: $s, project: $p, packages: $k}]' <<< "$INCLUDE") + done + + echo "matrix=$(jq -c '{include: .}' <<< "$INCLUDE")" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$INCLUDE" != "[]" ]]; then + echo "has_updates=true" >> "$GITHUB_OUTPUT" else - # Fallback - proceed - echo "packages=all" >> $GITHUB_OUTPUT - echo "has_updates=true" >> $GITHUB_OUTPUT + echo "has_updates=false" >> "$GITHUB_OUTPUT" fi update-obs: - name: Upload to OBS + name: Upload to OBS (${{ matrix.owner }}) needs: check-updates runs-on: ubuntu-latest if: needs.check-updates.outputs.has_updates == 'true' + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.check-updates.outputs.matrix) }} + env: + OBS_BASE_PROJECT: ${{ matrix.project }} steps: - name: Checkout @@ -179,12 +178,12 @@ jobs: # Use check-updates outputs when available if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then # Use version from check-updates job - echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT + echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}" elif [[ "${{ github.event_name }}" == "schedule" ]]; then # Scheduled run - dms-git only - echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT + echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT echo "Triggered by schedule: updating git package" elif [[ -n "${{ github.event.inputs.package }}" ]]; then # Manual workflow dispatch @@ -192,7 +191,7 @@ jobs: # Determine version for dms stable using the API # GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then - LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") + LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") if [[ -n "$LATEST_TAG" ]]; then echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "Using latest release from API: $LATEST_TAG" @@ -204,14 +203,14 @@ jobs: # Use filtered packages from check-updates when package="all" and no rebuild/tag specified if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then - echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT - echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})" + echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT + echo "Manual trigger: all (filtered to: ${{ matrix.packages }})" else echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT echo "Manual trigger: ${{ github.event.inputs.package }}" fi else - echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT + echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT fi @@ -311,20 +310,18 @@ jobs: cache-dependency-path: core/go.sum - name: Install OSC + env: + OBS_USERNAME: ${{ secrets[format('OBS_USERNAME{0}', matrix.suffix)] }} + OBS_PASSWORD: ${{ secrets[format('OBS_PASSWORD{0}', matrix.suffix)] }} run: | + : "${OBS_USERNAME:?OBS_USERNAME${{ matrix.suffix }} is not set}" "${OBS_PASSWORD:?OBS_PASSWORD${{ matrix.suffix }} is not set}" sudo apt-get update sudo apt-get install -y osc mkdir -p ~/.config/osc - cat > ~/.config/osc/oscrc << EOF - [general] - apiurl = https://api.opensuse.org - - [https://api.opensuse.org] - user = ${{ secrets.OBS_USERNAME }} - pass = ${{ secrets.OBS_PASSWORD }} - EOF - chmod 600 ~/.config/osc/oscrc + umask 077 + printf '[general]\napiurl = https://api.opensuse.org\n\n[https://api.opensuse.org]\nuser = %s\npass = %s\n' \ + "$OBS_USERNAME" "$OBS_PASSWORD" > ~/.config/osc/oscrc # Cache OBS bundled Go toolchains - name: Cache OBS bundled Go toolchains (dms-git) @@ -405,7 +402,7 @@ jobs: - name: Summary if: always() run: | - echo "### OBS Package Upload Summary" >> $GITHUB_STEP_SUMMARY + echo "### OBS Package Upload Summary (${{ matrix.owner }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY PACKAGES="${{ steps.packages.outputs.packages }}" @@ -458,10 +455,10 @@ jobs: case "$PKG" in dms) - echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY + echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms/dms)" >> $GITHUB_STEP_SUMMARY ;; dms-git) - echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY + echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY ;; esac done @@ -477,5 +474,5 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY fi - echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/home:AvengeMedia)." >> $GITHUB_STEP_SUMMARY + echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/${OBS_BASE_PROJECT})." >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/run-ppa.yml b/.github/workflows/run-ppa.yml index e367888d1..082a9e5d7 100644 --- a/.github/workflows/run-ppa.yml +++ b/.github/workflows/run-ppa.yml @@ -16,6 +16,15 @@ on: description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)" required: false default: "" + owners: + description: "PPA owners to publish to" + required: false + default: "all" + type: choice + options: + - all + - duxlabs + - avengemedia schedule: - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) @@ -44,6 +53,8 @@ jobs: - name: Check for updates id: check + env: + OWNERS: ${{ inputs.owners || 'all' }} run: | chmod +x distro/scripts/ppa-sync-plan.sh @@ -59,10 +70,20 @@ jobs: ARGS+=(--rebuild "$REBUILD_RELEASE") fi - TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log) + # Planned per owner: an empty new PPA needs everything, the old one may need nothing. + TARGETS_JSON='[]' + : > ppa-audit.log + for T in duxlabs: avengemedia:_LEGACY; do + OWNER="${T%%:*}" SUFFIX="${T#*:}" + [[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue + echo "~${OWNER}:" >> ppa-audit.log + PLAN=$(PPA_OWNER="$OWNER" distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2>> ppa-audit.log) + TARGETS_JSON=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --argjson p "$PLAN" \ + '. + [$p[] | {owner: $o, suffix: $s, target: .}]' <<< "$TARGETS_JSON") + done cat ppa-audit.log - TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")') + TARGETS=$(jq -r 'map(.owner + ":" + .target) | join(" ")' <<< "$TARGETS_JSON") if [[ "$TARGETS_JSON" != "[]" ]]; then echo "has_updates=true" >> "$GITHUB_OUTPUT" echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" @@ -76,7 +97,7 @@ jobs: fi upload-ppa: - name: Upload ${{ matrix.target }} + name: Upload ${{ matrix.owner }} ${{ matrix.target }} needs: check-updates runs-on: ubuntu-latest if: needs.check-updates.outputs.has_updates == 'true' @@ -84,9 +105,9 @@ jobs: strategy: fail-fast: false matrix: - target: ${{ fromJson(needs.check-updates.outputs.targets_json) }} + include: ${{ fromJson(needs.check-updates.outputs.targets_json) }} concurrency: - group: ppa-dms-${{ matrix.target }} + group: ppa-dms-${{ matrix.owner }}-${{ matrix.target }} cancel-in-progress: false steps: @@ -118,8 +139,9 @@ jobs: - name: Configure GPG env: - GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_KEY: ${{ secrets[format('GPG_PRIVATE_KEY{0}', matrix.suffix)] }} run: | + : "${GPG_KEY:?GPG_PRIVATE_KEY${{ matrix.suffix }} is not set}" echo "$GPG_KEY" | gpg --import GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2) echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV" @@ -127,9 +149,11 @@ jobs: - name: Upload target env: TARGET: ${{ matrix.target }} - LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }} - LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }} + PPA_OWNER: ${{ matrix.owner }} + LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets[format('LAUNCHPAD_SSH_PRIVATE_KEY{0}', matrix.suffix)] }} + LAUNCHPAD_SSH_LOGIN: ${{ secrets[format('LAUNCHPAD_SSH_LOGIN{0}', matrix.suffix)] }} run: | + : "${LAUNCHPAD_SSH_PRIVATE_KEY:?LAUNCHPAD_SSH_PRIVATE_KEY${{ matrix.suffix }} is not set}" "${LAUNCHPAD_SSH_LOGIN:?LAUNCHPAD_SSH_LOGIN${{ matrix.suffix }} is not set}" IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET" case "$PACKAGE" in @@ -138,7 +162,7 @@ jobs: *) echo "::error::Unknown package $PACKAGE"; exit 1 ;; esac - echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM" + echo "Uploading $PACKAGE to ~$PPA_OWNER/$PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM" bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM" - name: Summary @@ -146,6 +170,7 @@ jobs: run: | echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Owner:** ${{ matrix.owner }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY" - echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY" - echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY" + echo "- **DMS PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY" + echo "- **DMS-Git PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/run-xbps.yml b/.github/workflows/run-xbps.yml index 67826e54b..bb9e1ed60 100644 --- a/.github/workflows/run-xbps.yml +++ b/.github/workflows/run-xbps.yml @@ -43,11 +43,11 @@ jobs: - name: Check published commit id: check env: - MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json + MANIFEST_URL: https://void.duxlabs.dev/dms/current/manifest.json run: | SHOULD_BUILD=true if [ "${{ github.event_name }}" = "schedule" ]; then - # dms/current is shared with dux-repo; read this repo's own entry + # dux-repo's legacy greeter mirror also writes dms/current; read this repo's own entry PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" \ | jq -r --arg repo "${{ github.repository }}" '.sources[$repo] // .source_commit // empty' 2>/dev/null || true)" if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then @@ -159,8 +159,8 @@ jobs: - name: Configure repositories run: | # Append the repository to repos-remote templates so xbps-src translates it automatically - echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf - echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf + echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote.conf + echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf # Add any existing compiled packages to the build cache directory to avoid rebuilds if [ -d "r2-repo/current" ]; then @@ -278,36 +278,9 @@ jobs: fi - name: Index and sign repository - run: | - cd r2-repo/current - - rm -f *.sig - - # Guard: nothing to index if no .xbps files exist - if ! ls *.xbps 1>/dev/null 2>&1; then - echo "⚠️ No .xbps files found to index, skipping." - exit 0 - fi - - # Regenerate repo index - xbps-rindex -a $(pwd)/*.xbps - xbps-rindex --remove-obsoletes $(pwd) - - # Remove signatures left behind by obsolete packages. Signatures for - # unchanged immutable packages are retained byte-for-byte. - for sig in *.sig2; do - [ -e "$sig" ] || break - [ -f "${sig%.sig2}" ] || rm -f "$sig" - done - - # Sign repository - echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem - chmod 600 /tmp/xbps_privkey.pem - - xbps-rindex --sign --signedby "AvengeMedia " --privkey /tmp/xbps_privkey.pem $(pwd) - xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps - - rm -f /tmp/xbps_privkey.pem + env: + XBPS_PRIVATE_KEY: ${{ secrets.XBPS_PRIVATE_KEY }} + run: bash .github/scripts/publish-void-r2.sh index - name: Publish repository to R2 run: bash .github/scripts/publish-void-r2.sh publish diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index f6bb90447..cbad3e70a 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -795,6 +795,7 @@ func getCommonCommands() []*cobra.Command { systemdCmd, switchUserCmd, switchCmd, + migrateCmd, dmenuCmd, }...) } diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index 2d6b3fed5..b1534ed02 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -219,6 +219,7 @@ func runDoctor(cmd *cobra.Command, args []string) { checkSystemInfo(), checkVersions(qsMissingFeatures), checkDMSInstallation(), + checkPackageRepos(), checkWindowManagers(), qsFeatures, checkOptionalDependencies(), @@ -804,6 +805,36 @@ func checkDMSInstallation() []checkResult { return results } +func checkPackageRepos() []checkResult { + osInfo, err := distros.GetOSInfo() + if err != nil { + return nil + } + distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil) + if err != nil { + return nil + } + return packageReposResult(distro) +} + +func packageReposResult(distro distros.Distribution) []checkResult { + moves, ok := distros.PlanRepoMoves(distro) + if !ok { + return nil + } + url := doctorDocsURL + "#package-repos" + legacy := distros.LegacyRepoPaths(moves) + if len(legacy) > 0 { + return []checkResult{{catInstallation, "Package Repos", statusWarn, "Legacy repositories, run: " + migrateDuxCommand, strings.Join(legacy, ", "), url}} + } + for _, move := range moves { + if move.NewPresent { + return []checkResult{{catInstallation, "Package Repos", statusOK, "Current", "", url}} + } + } + return []checkResult{{catInstallation, "Package Repos", statusInfo, "No DMS package repositories", "", url}} +} + func checkWindowManagers() []checkResult { compositors := []struct { name, versionCmd, versionArg string diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index b2d0850c6..04b2f0b1b 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -30,6 +30,10 @@ var greeterCmd = &cobra.Command{ }, } +func greeterRepoURL(config distros.DistroConfig) string { + return config.Constructor(config, nil).GetPackageMapping(0)["dms-greeter"].RepoURL +} + func isLegacyWrapperScript(path string) bool { file, err := os.Open(path) if err != nil { @@ -63,13 +67,14 @@ func greeterPackageInstallHint() string { case distros.FamilySUSE: return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)" case distros.FamilyUbuntu: - return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)" + repo := greeterRepoURL(config) + return fmt.Sprintf("Install with 'sudo apt install dms-greeter' (requires %s: sudo add-apt-repository %s)", repo, repo) case distros.FamilyFedora: - return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" + return fmt.Sprintf("Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable %s)", greeterRepoURL(config)) case distros.FamilyArch: return "Install from AUR with 'paru -S dux-greeter-bin' or 'yay -S dux-greeter-bin'" case distros.FamilyVoid: - return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)" + return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDepsRepo + "' | sudo tee /etc/xbps.d/dux.conf)" default: return "Install the dms-greeter package for your distribution" } diff --git a/core/cmd/dms/commands_migrate.go b/core/cmd/dms/commands_migrate.go new file mode 100644 index 000000000..b00302088 --- /dev/null +++ b/core/cmd/dms/commands_migrate.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/duxlabs-dev/dux-shell/core/internal/distros" + "github.com/duxlabs-dev/dux-shell/core/internal/privesc" + "github.com/spf13/cobra" +) + +const migrateDuxCommand = "dms migrate dux" + +var ( + migrateDryRun bool + migrateAssumeYes bool +) + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "One-time migrations for existing installs", +} + +var migrateDuxCmd = &cobra.Command{ + Use: "dux", + Short: "Move DMS package repositories to the Dux Labs owner", + Long: `Move the DMS package repositories from the legacy AvengeMedia owner to Dux Labs. + +Adds the Dux Labs repository for every legacy one found, then removes the legacy +one. Installed packages are not changed; the next update comes from the new repo.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := runMigrateDux(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + migrateDuxCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Print the commands without running them") + migrateDuxCmd.Flags().BoolVarP(&migrateAssumeYes, "yes", "y", false, "Do not ask for confirmation") + migrateCmd.AddCommand(migrateDuxCmd) +} + +func runMigrateDux() error { + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("detecting distribution: %w", err) + } + distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil) + if err != nil { + return err + } + + moves, ok := distros.PlanRepoMoves(distro) + if !ok { + fmt.Printf("Nothing to migrate on %s.\n", osInfo.PrettyName) + return nil + } + mover := distro.(distros.RepoMover) + commands := mover.RepoMoveCommands(moves) + if len(commands) == 0 { + fmt.Println("Already migrated.") + return nil + } + if osInfo.Immutable { + imageMover, ok := distro.(distros.ImageRepoMover) + if !ok { + return fmt.Errorf("%s is an image-based system; move the repositories with your image tooling", osInfo.PrettyName) + } + printRepoMoves(moves, imageMover.ImageRepoMoveCommands(moves)) + fmt.Printf("\n%s is image-based, so run these yourself.\n", osInfo.PrettyName) + return nil + } + + printRepoMoves(moves, commands) + if migrateDryRun { + return nil + } + if !migrateAssumeYes && !askYes("\nContinue? [y/N] ") { + return errors.New("cancelled") + } + + ctx := context.Background() + if privesc.CheckCached(ctx) != nil { + if err := privesc.Run(ctx, "", "true"); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + err = runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error { + return mover.ApplyRepoMoves(ctx, moves, "", progressChan) + }) + if err != nil { + return err + } + fmt.Println("Done. 'dms doctor' shows the result; the shell notice clears on its next start.") + return nil +} + +func printRepoMoves(moves []distros.RepoMove, commands []string) { + fmt.Println("Repositories to move:") + for _, move := range moves { + switch { + case move.OldPresent: + fmt.Printf(" %s → %s\n", move.Old, move.New) + case !move.NewPresent && move.NewPath != "": + fmt.Printf(" add %s\n", move.New) + } + } + fmt.Println("\nCommands:") + for _, command := range commands { + fmt.Printf(" %s\n", command) + } +} diff --git a/core/cmd/dms/commands_migrate_test.go b/core/cmd/dms/commands_migrate_test.go new file mode 100644 index 000000000..1deb1f070 --- /dev/null +++ b/core/cmd/dms/commands_migrate_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "testing" + + "github.com/duxlabs-dev/dux-shell/core/internal/distros" +) + +type repoMoverFake struct { + distros.Distribution + moves []distros.RepoMove +} + +func (f *repoMoverFake) RepoMoves() []distros.RepoMove { return f.moves } +func (f *repoMoverFake) ApplyRepoMoves(context.Context, []distros.RepoMove, string, chan<- distros.InstallProgressMsg) error { + return nil +} +func (f *repoMoverFake) RepoMoveCommands([]distros.RepoMove) []string { return nil } + +type plainDistroFake struct{ distros.Distribution } + +func TestRunWithProgressOutputPrintsRepeatedLinesOnce(t *testing.T) { + var out bytes.Buffer + err := runWithProgressOutputTo(&out, func(ch chan<- distros.InstallProgressMsg) error { + ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."} + ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."} + ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."} + return nil + }) + if err != nil { + t.Fatal(err) + } + want := "Removing COPR repo...\n Repository successfully removed.\n" + if out.String() != want { + t.Fatalf("got %q, want %q", out.String(), want) + } +} + +func TestCheckPackageRepos(t *testing.T) { + legacy := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms.repo", OldPresent: true} + legacyDeps := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/danklinux", New: "duxlabs/dux-repo", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo", OldPresent: true} + current := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", NewPresent: true} + absent := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms"} + + cases := []struct { + name string + moves []distros.RepoMove + status status + message string + }{ + {"legacy", []distros.RepoMove{legacy, legacyDeps, current}, statusWarn, "Legacy repositories, run: dms migrate dux"}, + {"current", []distros.RepoMove{current, absent}, statusOK, "Current"}, + {"none", []distros.RepoMove{absent}, statusInfo, "No DMS package repositories"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + results := packageReposResult(&repoMoverFake{moves: tc.moves}) + if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message { + t.Fatalf("got %+v, want %v %q", results, tc.status, tc.message) + } + if tc.status == statusWarn && results[0].details != legacy.OldPath+", "+legacyDeps.OldPath { + t.Fatalf("details = %q, want the legacy paths on one line", results[0].details) + } + }) + } + + voidConf := "/etc/xbps.d/custom.conf" + voidMoves := []distros.RepoMove{ + {Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/dms/current", New: "https://void.duxlabs.dev/dms/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true}, + {Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/danklinux/current", New: "https://void.duxlabs.dev/dux-repo/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true}, + } + if results := packageReposResult(&repoMoverFake{moves: voidMoves}); len(results) != 1 || results[0].status != statusWarn || results[0].details != voidConf { + t.Fatalf("got %+v, want one warning naming %s once", results, voidConf) + } + + if results := packageReposResult(&plainDistroFake{}); results != nil { + t.Fatalf("printed %+v on a family without repo moves", results) + } +} diff --git a/core/cmd/dms/commands_switch.go b/core/cmd/dms/commands_switch.go index b00d0aae8..d10a01e90 100644 --- a/core/cmd/dms/commands_switch.go +++ b/core/cmd/dms/commands_switch.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "strings" @@ -142,7 +143,7 @@ func runSwitch(channel string, packages []string) error { if shellChanges { fmt.Println("The shell restarts when the switch is done.") } - if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") { + if !switchAssumeYes && !askYes("\nContinue? [y/N] ") { return errors.New("cancelled") } @@ -171,7 +172,7 @@ func runSwitch(channel string, packages []string) error { if snapshotLabel == "beta" && !switchAssumeYes { if path, ok := backup.LatestSnapshot("stable"); ok { fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path) - if askSwitch("") { + if askYes("") { restore = path } } @@ -185,28 +186,39 @@ func runSwitch(channel string, packages []string) error { } func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error { + return runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error { + return swapper.SwapVariants(ctx, swaps, "", progressChan) + }) +} + +func runWithProgressOutput(run func(chan<- distros.InstallProgressMsg) error) error { + return runWithProgressOutputTo(os.Stdout, run) +} + +func runWithProgressOutputTo(out io.Writer, run func(chan<- distros.InstallProgressMsg) error) error { progressChan := make(chan distros.InstallProgressMsg, 100) - swapErr := make(chan error, 1) + runErr := make(chan error, 1) go func() { defer close(progressChan) - swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan) + runErr <- run(progressChan) }() - // runWithProgress repeats the step on every output line, so only print it when it changes. - lastStep := "" + // runWithProgress repeats the step and the last output line on every tick, so only print changes. + lastStep, lastLog := "", "" for msg := range progressChan { switch { case msg.Error != nil: fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error) case msg.Step != "" && msg.Step != lastStep: - fmt.Println(msg.Step) + fmt.Fprintln(out, msg.Step) lastStep = msg.Step } - if msg.LogOutput != "" { - fmt.Printf(" %s\n", msg.LogOutput) + if msg.LogOutput != "" && msg.LogOutput != lastLog { + fmt.Fprintf(out, " %s\n", msg.LogOutput) + lastLog = msg.LogOutput } } - return <-swapErr + return <-runErr } // restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the @@ -268,7 +280,7 @@ func switchLabel(m distros.PackageMapping) string { return m.RepoURL + "/" + m.Name } -func askSwitch(prompt string) bool { +func askYes(prompt string) bool { if !stdinIsTTY() { return false } diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index cd7d5cc5d..6a17a7a40 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -502,7 +502,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha progressChan <- InstallProgressMsg{ Phase: phase, Progress: endProgress, - Step: "Installation step complete", + Step: stepMessage, IsComplete: false, LogOutput: lastOutput, } @@ -541,7 +541,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha progressChan <- InstallProgressMsg{ Phase: phase, Progress: progress, - Step: "Installing...", + Step: stepMessage, IsComplete: false, LogOutput: lastOutput, } diff --git a/core/internal/distros/debian.go b/core/internal/distros/debian.go index e3eca75b3..3c919937d 100644 --- a/core/internal/distros/debian.go +++ b/core/internal/distros/debian.go @@ -159,11 +159,11 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager // DMS packages from OBS with variant support "dms (Dux Shell)": d.getDmsMapping(variants["dms (Dux Shell)"]), "quickshell": d.getQuickshellMapping(variants["quickshell"]), - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"), - "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps), + "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps), } if wm == deps.WindowManagerNiri { @@ -177,14 +177,14 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} + return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit} } - return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} + return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS} } func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { if forceQuickshellGit || variant == deps.VariantGit { - return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } // Debian 13 ships stable quickshell in trixie-backports only. if d.debianNeedsQuickshellBackports() { @@ -244,16 +244,16 @@ func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, syst func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } - return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: obsDeps} } func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } - return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: obsDeps} } func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { @@ -491,6 +491,62 @@ func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []s return append(args, packages...) } +func (d *DebianDistribution) RepoMoves() []RepoMove { + if !obsMigrationReady || d.isPika() { + return nil + } + return obsAptChannel.moves(repoPathExists) +} + +func (d *DebianDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if err := d.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil { + return err + } + removes := repoMoveRemoves(moves) + for _, move := range removes { + command := fmt.Sprintf("rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old)) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.30, + Step: fmt.Sprintf("Removing OBS repo %s...", move.Old), + NeedsSudo: true, + CommandInfo: "sudo " + command, + } + if err := d.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { + return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err) + } + } + if len(removes) == 0 { + return nil + } + return d.aptUpdate(ctx, sudoPassword, progressChan) +} + +func (d *DebianDistribution) RepoMoveCommands(moves []RepoMove) []string { + osInfo, err := GetOSInfo() + if err != nil { + osInfo = &OSInfo{} + } + return debianRepoMoveCommands(moves, debianOBSRepoVersion(osInfo, readDebianAPTSuites()), debianRepoArchitecture(osInfo.Architecture)) +} + +func debianRepoMoveCommands(moves []RepoMove, dist, arch string) []string { + var commands []string + for _, add := range repoMoveAdds(moves) { + baseURL := obsBaseURL(add.RepoURL, dist) + commands = append(commands, + fmt.Sprintf("curl -fsSL %s/Release.key | gpg --dearmor | sudo tee %s", baseURL, obsAptKeyring(add.RepoURL)), + fmt.Sprintf("echo 'deb [signed-by=%s arch=%s] %s/ /' | sudo tee %s", obsAptKeyring(add.RepoURL), arch, baseURL, obsAptListFile(add.RepoURL))) + } + for _, move := range repoMoveRemoves(moves) { + commands = append(commands, fmt.Sprintf("sudo rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old))) + } + if len(commands) > 0 { + commands = append(commands, "sudo apt-get update") + } + return commands +} + func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { enabledRepos := make(map[string]bool) @@ -506,20 +562,17 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) - // RepoURL format: "home:AvengeMedia:danklinux" - repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/") - repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-") - baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, obsRepo) + baseURL := obsBaseURL(pkg.RepoURL, obsRepo) // Check if repository already exists - listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName) + listFile := obsAptListFile(pkg.RepoURL) if debianOBSListFileMatches(listFile, obsRepo) { d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) enabledRepos[pkg.RepoURL] = true continue } - keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) + keyringPath := obsAptKeyring(pkg.RepoURL) // Create keyrings directory if it doesn't exist mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index 2d90466b3..17650ffe4 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -167,7 +167,7 @@ func fedoraInstalledFromCOPR(pkg, coprRepo string) bool { if err != nil { return rpmLooksLikeGitBuild(pkg) } - return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":")) + return coprFromRepoMatches(string(out), coprRepo) } // niri-git carries "git" in its version; dms-git only differs by its Epoch of 2. @@ -187,15 +187,20 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw for _, swap := range swaps { command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name) - disabledRepo := "" + var disabledRepos []string if swap.From.Name == swap.To.Name { // Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2). + // An un-migrated host still has it under the legacy owner, so every alias goes. if swap.From.Repository == RepoTypeCOPR { - disable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr disable -y "+swap.From.RepoURL) - if err := f.runWithProgress(disable, progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { - return fmt.Errorf("failed to disable COPR repo %s: %w", swap.From.RepoURL, err) + for _, repo := range coprAliases(swap.From.RepoURL) { + if !repoPathExists(coprRepoFile(repo)) { + continue + } + if err := f.coprRepoOp(ctx, "disable", repo, sudoPassword, progressChan); err != nil { + return err + } + disabledRepos = append(disabledRepos, repo) } - disabledRepo = swap.From.RepoURL } command = "dnf distro-sync -y " + swap.To.Name } @@ -207,11 +212,10 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw NeedsSudo: true, CommandInfo: "sudo " + command, } - if err := f.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil { - if disabledRepo != "" { - reenable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y "+disabledRepo) - if reErr := reenable.Run(); reErr != nil { - f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", disabledRepo, reErr)) + if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil { + for _, repo := range disabledRepos { + if reErr := privescExec(ctx, sudoPassword, "dnf copr enable -y "+repo).Run(); reErr != nil { + f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", repo, reErr)) } } return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err) @@ -220,6 +224,60 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw return nil } +func (f *FedoraDistribution) coprRepoOp(ctx context.Context, verb, repo, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + command := fmt.Sprintf("dnf copr %s -y %s", verb, repo) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.30, + Step: fmt.Sprintf("Running dnf copr %s %s...", verb, repo), + NeedsSudo: true, + CommandInfo: "sudo " + command, + } + if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { + return fmt.Errorf("failed to %s COPR repo %s: %w", verb, repo, err) + } + return nil +} + +func (f *FedoraDistribution) RepoMoves() []RepoMove { + return coprChannel.moves(repoPathExists) +} + +func (f *FedoraDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if err := f.enableCOPRRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil { + return err + } + for _, move := range repoMoveRemoves(moves) { + if err := f.coprRepoOp(ctx, "remove", move.Old, sudoPassword, progressChan); err != nil { + return err + } + } + return nil +} + +func (f *FedoraDistribution) RepoMoveCommands(moves []RepoMove) []string { + var commands []string + for _, add := range repoMoveAdds(moves) { + commands = append(commands, "sudo dnf copr enable -y "+add.RepoURL) + } + for _, move := range repoMoveRemoves(moves) { + commands = append(commands, "sudo dnf copr remove -y "+move.Old) + } + return commands +} + +// Shown to the user, never run: image-based Fedora has no dnf copr. +func (f *FedoraDistribution) ImageRepoMoveCommands(moves []RepoMove) []string { + var commands []string + for _, add := range repoMoveAdds(moves) { + commands = append(commands, f.coprRepoFileCommand(add.RepoURL)) + } + for _, move := range repoMoveRemoves(moves) { + commands = append(commands, "sudo rm -f "+move.OldPath) + } + return commands +} + func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) } @@ -228,7 +286,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager packages := map[string]PackageMapping{ // Standard DNF packages "git": {Name: "git", Repository: RepoTypeSystem}, - "ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + "ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: coprDeps}, "kitty": {Name: "kitty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, @@ -236,11 +294,11 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager // COPR packages "quickshell": f.getQuickshellMapping(variants["quickshell"]), - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"), - "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, coprDeps), + "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: coprDeps}, "dms (Dux Shell)": f.getDmsMapping(variants["dms (Dux Shell)"]), - "danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, - "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"), + "danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: coprDeps}, + "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, coprDeps), } switch wm { @@ -259,10 +317,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager } if f.isEL() { - // EPEL 10 has neither, so the danklinux COPR builds them for the epel-10 chroots. + // EPEL 10 has neither, so the deps COPR builds them for the epel-10 chroots. for _, name := range []string{"alacritty", "xwayland-satellite"} { if _, ok := packages[name]; ok { - packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} + packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: coprDeps} } } } @@ -272,16 +330,16 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { if forceQuickshellGit || variant == deps.VariantGit { - return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: coprDeps} } - return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} + return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: coprDeps} } func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"} + return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMSGit} } - return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"} + return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMS} } func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { @@ -648,7 +706,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL), } - cmd := privesc.ExecCommand(ctx, sudoPassword, + cmd := privescExec(ctx, sudoPassword, fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL)) output, err := cmd.CombinedOutput() if err != nil { @@ -662,7 +720,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac // Special handling for niri COPR repo - set priority=1 if pkg.RepoURL == "yalter/niri-git" { f.log("Setting priority=1 for niri-git COPR repo...") - repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo" + repoFile := coprRepoFile("yalter/niri-git") progressChan <- InstallProgressMsg{ Phase: PhaseSystemPackages, Progress: 0.22, @@ -672,7 +730,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile), } - priorityCmd := privesc.ExecCommand(ctx, sudoPassword, + priorityCmd := privescExec(ctx, sudoPassword, fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile)) priorityOutput, err := priorityCmd.CombinedOutput() if err != nil { @@ -761,11 +819,6 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st } // Image-based Fedora has rpm-ostree but no dnf, so the .repo file is fetched directly. -func coprRepoFilePath(repo string) string { - owner, project, _ := strings.Cut(repo, "/") - return fmt.Sprintf("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:%s:%s.repo", owner, project) -} - func (f *FedoraDistribution) coprRepoFileCommand(repo string) string { owner, project, _ := strings.Cut(repo, "/") chroot := "fedora-$(rpm -E %fedora)" @@ -773,7 +826,7 @@ func (f *FedoraDistribution) coprRepoFileCommand(repo string) string { chroot = "epel-$(rpm -E %rhel)" } return fmt.Sprintf("sudo curl -fsSLo %s https://copr.fedorainfracloud.org/coprs/%s/%s/repo/%s/%s-%s-%s.repo", - coprRepoFilePath(repo), owner, project, chroot, owner, project, chroot) + coprRepoFile(repo), owner, project, chroot, owner, project, chroot) } // Shown to the user, never run. Terra-only packages have no rpm-ostree path. @@ -796,7 +849,11 @@ func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm de } uninstall = append(uninstall, swap.From.Name) if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR { - commands = append(commands, "sudo rm -f "+coprRepoFilePath(swap.From.RepoURL)) + var files []string + for _, repo := range coprAliases(swap.From.RepoURL) { + files = append(files, coprRepoFile(repo)) + } + commands = append(commands, "sudo rm -f "+strings.Join(files, " ")) } } dnfPkgs, coprPkgs, manualPkgs, _ := f.categorizePackages(dependencies, wm, reinstallFlags, skip) @@ -811,7 +868,7 @@ func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm de seen[pkg.RepoURL] = true commands = append(commands, f.coprRepoFileCommand(pkg.RepoURL)) if pkg.RepoURL == "yalter/niri-git" { - commands = append(commands, "echo priority=1 | sudo tee -a /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo") + commands = append(commands, "echo priority=1 | sudo tee -a "+coprRepoFile("yalter/niri-git")) } } for _, dep := range dependencies { diff --git a/core/internal/distros/fedora_test.go b/core/internal/distros/fedora_test.go index 0dd393c8b..2630ff0ba 100644 --- a/core/internal/distros/fedora_test.go +++ b/core/internal/distros/fedora_test.go @@ -9,8 +9,8 @@ import ( func TestFedoraPackageMapping_EnterpriseLinuxUsesCOPRForMissingEPELPackages(t *testing.T) { el := NewFedoraDistribution(Registry["rhel"], nil).GetPackageMapping(deps.WindowManagerNiri) for _, name := range []string{"alacritty", "xwayland-satellite"} { - if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "avengemedia/danklinux" { - t.Errorf("rhel %s = %+v, want avengemedia/danklinux COPR", name, el[name]) + if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "duxlabs/dux-repo" { + t.Errorf("rhel %s = %+v, want duxlabs/dux-repo COPR", name, el[name]) } } diff --git a/core/internal/distros/immutable_test.go b/core/internal/distros/immutable_test.go index efa2cb8a7..62e59ac5b 100644 --- a/core/internal/distros/immutable_test.go +++ b/core/internal/distros/immutable_test.go @@ -148,7 +148,7 @@ func TestFedoraLayerCommands(t *testing.T) { got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n") for _, want := range []string{ - "_copr:copr.fedorainfracloud.org:avengemedia:dms.repo https://copr.fedorainfracloud.org/coprs/avengemedia/dms/repo/fedora-$(rpm -E %fedora)/avengemedia-dms-fedora-$(rpm -E %fedora).repo", + "_copr:copr.fedorainfracloud.org:duxlabs:dms.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dms/repo/fedora-$(rpm -E %fedora)/duxlabs-dms-fedora-$(rpm -E %fedora).repo", "_copr:copr.fedorainfracloud.org:yalter:niri-git.repo", "echo priority=1 | sudo tee -a", "sudo rpm-ostree install xwayland-satellite dms niri matugen", @@ -163,8 +163,8 @@ func TestFedoraLayerCommands(t *testing.T) { t.Fatalf("unexpected %q in:\n%s", unwanted, got) } } - if strings.Count(got, "avengemedia:danklinux.repo https") != 1 { - t.Fatalf("danklinux repo must be fetched exactly once:\n%s", got) + if strings.Count(got, "duxlabs:dux-repo.repo https") != 1 { + t.Fatalf("deps repo must be fetched exactly once:\n%s", got) } } @@ -181,7 +181,7 @@ func TestFedoraLayerCommandsSwapVariants(t *testing.T) { installed := map[string]bool{"dms-greeter": true, "dms": true} f.rpmInstalled = func(pkg string) bool { return installed[pkg] } orig := installedFromCOPR - installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "avengemedia/dms-git" } + installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "duxlabs/dms-git" } t.Cleanup(func() { installedFromCOPR = orig }) dependencies := []deps.Dependency{ @@ -193,7 +193,7 @@ func TestFedoraLayerCommandsSwapVariants(t *testing.T) { got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, reinstall, nil), "\n") for _, want := range []string{ - "sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms-git.repo", + "sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dms-git.repo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms-git.repo", "sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter", } { if !strings.Contains(got, want) { diff --git a/core/internal/distros/opensuse.go b/core/internal/distros/opensuse.go index 088c33e82..f86728057 100644 --- a/core/internal/distros/opensuse.go +++ b/core/internal/distros/opensuse.go @@ -132,6 +132,70 @@ func (o *OpenSUSEDistribution) SwapVariants(ctx context.Context, swaps []Variant return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85) } +func (o *OpenSUSEDistribution) RepoMoves() []RepoMove { + if !obsMigrationReady { + return nil + } + return obsZypperChannel.moves(repoPathExists) +} + +func (o *OpenSUSEDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if err := o.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil { + return err + } + for _, move := range repoMoveRemoves(moves) { + command := "zypper removerepo " + obsZypperAlias(move.Old) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.30, + Step: fmt.Sprintf("Removing OBS repo %s...", move.Old), + NeedsSudo: true, + CommandInfo: "sudo " + command, + } + if err := o.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { + return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err) + } + } + return nil +} + +func (o *OpenSUSEDistribution) RepoMoveCommands(moves []RepoMove) []string { + osInfo, err := GetOSInfo() + if err != nil { + osInfo = &OSInfo{} + } + return opensuseRepoMoveCommands(moves, opensuseOBSDist(osInfo)) +} + +func opensuseRepoMoveCommands(moves []RepoMove, dist string) []string { + var commands []string + adds := repoMoveAdds(moves) + for _, add := range adds { + commands = append(commands, "sudo zypper addrepo -f "+obsZypperRepoURL(add.RepoURL, dist)) + } + if len(adds) > 0 { + commands = append(commands, "sudo zypper --gpg-auto-import-keys refresh") + } + for _, move := range repoMoveRemoves(moves) { + commands = append(commands, "sudo zypper removerepo "+obsZypperAlias(move.Old)) + } + return commands +} + +func opensuseOBSDist(osInfo *OSInfo) string { + switch osInfo.Distribution.ID { + case "opensuse-leap": + return "openSUSE_Leap_" + osInfo.VersionID + case "opensuse-slowroll": + return "openSUSE_Slowroll" + } + return "openSUSE_Tumbleweed" +} + +func obsZypperRepoURL(repo, dist string) string { + return obsBaseURL(repo, dist) + "/" + repo + ".repo" +} + func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency { gitInstalled := o.packageInstalled("dms-greeter-git") dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter")) @@ -154,11 +218,11 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag // DMS packages from OBS "dms (Dux Shell)": o.getDmsMapping(variants["dms (Dux Shell)"]), "quickshell": o.getQuickshellMapping(variants["quickshell"]), - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"), - "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"), + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps), + "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps}, + "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps), } switch wm { @@ -179,28 +243,28 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} + return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit} } - return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} + return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS} } func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { if forceQuickshellGit || variant == deps.VariantGit { - return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } - return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: obsDeps} } func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } return PackageMapping{Name: "niri", Repository: RepoTypeSystem} } func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps} } return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} } @@ -490,26 +554,16 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac return fmt.Errorf("failed to get OS info: %w", err) } - obsDistroVersion := "openSUSE_Tumbleweed" - switch osInfo.Distribution.ID { - case "opensuse-leap": - obsDistroVersion = fmt.Sprintf("openSUSE_Leap_%s", osInfo.VersionID) - case "opensuse-slowroll": - obsDistroVersion = "openSUSE_Slowroll" - } + obsDistroVersion := opensuseOBSDist(osInfo) for _, pkg := range obsPkgs { if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) - // RepoURL format: "home:AvengeMedia:danklinux" - repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/") - repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-") - repoURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s/%s.repo", - repoPath, obsDistroVersion, pkg.RepoURL) + repoURL := obsZypperRepoURL(pkg.RepoURL, obsDistroVersion) - checkCmd := exec.CommandContext(ctx, "zypper", "repos", repoName) - if checkCmd.Run() == nil { + // zypper stores the repo under the alias from the .repo section header, home__. + if repoPathExists(obsZypperRepoFile(pkg.RepoURL)) { o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) enabledRepos[pkg.RepoURL] = true continue diff --git a/core/internal/distros/repos.go b/core/internal/distros/repos.go new file mode 100644 index 000000000..b01991314 --- /dev/null +++ b/core/internal/distros/repos.go @@ -0,0 +1,257 @@ +package distros + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/duxlabs-dev/dux-shell/core/internal/privesc" + "github.com/duxlabs-dev/dux-shell/core/internal/site" +) + +const ( + coprOwner = "duxlabs" + obsHome = "home:duxlabs" + ppaOwner = "duxlabs" + + legacyCoprOwner = "avengemedia" + legacyObsHome = "home:AvengeMedia" + legacyPpaOwner = "avengemedia" + + projectDMS = "dms" + projectDMSGit = "dms-git" + projectDeps = "dux-repo" + legacyProjectDeps = "danklinux" + // The 2.0 docs told users to enable duxlabs/dux before the deps slug became dux-repo. + retiredProjectDeps = "dux" + + voidHost = site.VoidRepo + legacyVoidHost = "https://void.danklinux.com" + voidConfGlob = "/etc/xbps.d/*.conf" +) + +// The duxlabs OBS projects are not published yet, so Debian and openSUSE stay on the legacy ones. +const obsMigrationReady = false + +var dmsProjects = []string{projectDMS, projectDMSGit, projectDeps} + +func legacyProject(project string) string { + if project == projectDeps { + return legacyProjectDeps + } + return project +} + +func coprRepo(project string) string { return coprOwner + "/" + project } +func legacyCoprRepo(project string) string { return legacyCoprOwner + "/" + project } +func ppaRepo(project string) string { return "ppa:" + ppaOwner + "/" + project } +func legacyPpaRepo(project string) string { return "ppa:" + legacyPpaOwner + "/" + project } +func legacyObsRepo(project string) string { return legacyObsHome + ":" + project } +func newObsRepo(project string) string { return obsHome + ":" + project } + +// obsRepo is what the mappings install from; the move tables use newObsRepo regardless of the gate. +func obsRepo(project string) string { + if !obsMigrationReady { + return legacyObsRepo(legacyProject(project)) + } + return newObsRepo(project) +} + +var ( + coprDMS, coprDMSGit, coprDeps = coprRepo(projectDMS), coprRepo(projectDMSGit), coprRepo(projectDeps) + obsDMS, obsDMSGit, obsDeps = obsRepo(projectDMS), obsRepo(projectDMSGit), obsRepo(projectDeps) + ppaDMS, ppaDMSGit, ppaDeps = ppaRepo(projectDMS), ppaRepo(projectDMSGit), ppaRepo(projectDeps) +) + +// Each must match what the package manager itself derives from the RepoURL. +func coprRepoID(repo string) string { + return "copr:copr.fedorainfracloud.org:" + strings.ReplaceAll(repo, "/", ":") +} +func coprRepoFile(repo string) string { return "/etc/yum.repos.d/_" + coprRepoID(repo) + ".repo" } +func obsAptName(repo string) string { return strings.ReplaceAll(repo, ":", "-") } +func obsAptListFile(repo string) string { + return "/etc/apt/sources.list.d/" + obsAptName(repo) + ".list" +} +func obsAptKeyring(repo string) string { return "/etc/apt/keyrings/" + obsAptName(repo) + ".gpg" } +func obsZypperAlias(repo string) string { return strings.ReplaceAll(repo, ":", "_") } +func obsZypperRepoFile(repo string) string { + return "/etc/zypp/repos.d/" + obsZypperAlias(repo) + ".repo" +} +func obsBaseURL(repo, dist string) string { + return "https://download.opensuse.org/repositories/" + strings.ReplaceAll(repo, ":", ":/") + "/" + dist +} + +// add-apt-repository names the file -ubuntu--.sources (24.04+) or .list. +func ppaSourcesGlobs(repo string) []string { + owner, project, _ := strings.Cut(strings.TrimPrefix(repo, "ppa:"), "/") + stem := "/etc/apt/sources.list.d/" + owner + "-ubuntu-" + project + "-*" + return []string{stem + ".sources", stem + ".list"} +} + +// A current COPR mapping may still be installed from its legacy owner. +func coprAliases(repo string) []string { + owner, project, _ := strings.Cut(repo, "/") + if owner != coprOwner { + return []string{repo} + } + return []string{repo, legacyCoprRepo(legacyProject(project))} +} + +func coprRepoIDs(repo string) []string { + var ids []string + for _, alias := range coprAliases(repo) { + ids = append(ids, coprRepoID(alias)) + } + return ids +} + +func coprFromRepoMatches(fromRepo, repo string) bool { + return slices.Contains(coprRepoIDs(repo), strings.TrimSpace(fromRepo)) +} + +// Test seams. +var ( + repoGlob = func(pattern string) []string { + matches, _ := filepath.Glob(pattern) + return matches + } + repoPathExists = func(pattern string) bool { return len(repoGlob(pattern)) > 0 } + repoReadFile = os.ReadFile + privescExec = privesc.ExecCommand +) + +// The series is one word, so the retired dux-* glob must not count the dux-repo-* sibling. +func ppaSourcesExists(pattern string) bool { + return slices.ContainsFunc(repoGlob(pattern), func(path string) bool { return ppaSeriesMatch(pattern, path) }) +} + +func ppaSeriesMatch(pattern, path string) bool { + stem, ext, _ := strings.Cut(pattern, "*") + series := strings.TrimSuffix(strings.TrimPrefix(path, stem), ext) + return series != "" && !strings.Contains(series, "-") +} + +type RepoMove struct { + Kind RepositoryType + Old, New string // RepoURL form, e.g. "avengemedia/danklinux" → "duxlabs/dux-repo" + OldPath, NewPath string // file (or glob) whose presence means the repo is enabled + OldPresent, NewPresent bool +} + +type RepoMover interface { + RepoMoves() []RepoMove + ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error + RepoMoveCommands(moves []RepoMove) []string +} + +// For image-based hosts, where the package manager cannot run the moves. +type ImageRepoMover interface { + ImageRepoMoveCommands(moves []RepoMove) []string +} + +func PlanRepoMoves(d Distribution) ([]RepoMove, bool) { + mover, ok := d.(RepoMover) + if !ok { + return nil, false + } + moves := mover.RepoMoves() + return moves, len(moves) > 0 +} + +type repoChannel struct { + kind RepositoryType + repo, legacyRepo func(project string) string + paths func(repo string) []string + retiredDeps bool // the channel also published the deps project under retiredProjectDeps +} + +func (c repoChannel) legacyRepos(project string) []string { + repos := []string{c.legacyRepo(legacyProject(project))} + if project == projectDeps && c.retiredDeps { + repos = append(repos, c.repo(retiredProjectDeps)) + } + return repos +} + +func (c repoChannel) moves(exists func(string) bool) []RepoMove { + var moves []RepoMove + for _, project := range dmsProjects { + for _, old := range c.legacyRepos(project) { + move := RepoMove{Kind: c.kind, Old: old, New: c.repo(project)} + move.OldPath, move.OldPresent = firstExisting(exists, c.paths(move.Old)) + move.NewPath, move.NewPresent = firstExisting(exists, c.paths(move.New)) + moves = append(moves, move) + } + } + return moves +} + +func firstExisting(exists func(string) bool, paths []string) (string, bool) { + for _, path := range paths { + if exists(path) { + return path, true + } + } + return paths[0], false +} + +var ( + coprChannel = repoChannel{RepoTypeCOPR, coprRepo, legacyCoprRepo, func(r string) []string { return []string{coprRepoFile(r)} }, true} + obsAptChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsAptListFile(r)} }, false} + obsZypperChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsZypperRepoFile(r)} }, false} + ppaChannel = repoChannel{RepoTypePPA, ppaRepo, legacyPpaRepo, ppaSourcesGlobs, true} +) + +func (m RepoMove) needsAdd() bool { return m.OldPresent && !m.NewPresent } +func (m RepoMove) needsRemove() bool { return m.OldPresent } + +// The enable*Repos helpers only read RepoURL; two legacy rows can share a target. +func repoMoveAdds(moves []RepoMove) []PackageMapping { + var adds []PackageMapping + seen := map[string]bool{} + for _, move := range moves { + if !move.needsAdd() || seen[move.New] { + continue + } + seen[move.New] = true + adds = append(adds, PackageMapping{Repository: move.Kind, RepoURL: move.New}) + } + return adds +} + +func repoMoveRemoves(moves []RepoMove) []RepoMove { + var removes []RepoMove + for _, move := range moves { + if move.needsRemove() { + removes = append(removes, move) + } + } + return removes +} + +func (b *BaseDistribution) aptUpdate(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.36, + Step: "Updating package lists...", + NeedsSudo: true, + CommandInfo: "sudo apt-get update", + } + if err := b.runWithProgress(privescExec(ctx, sudoPassword, "apt-get update"), progressChan, PhaseSystemPackages, 0.36, 0.40); err != nil { + return fmt.Errorf("failed to update package lists: %w", err) + } + return nil +} + +func LegacyRepoPaths(moves []RepoMove) []string { + paths := []string{} + for _, move := range repoMoveRemoves(moves) { + if !slices.Contains(paths, move.OldPath) { + paths = append(paths, move.OldPath) + } + } + return paths +} diff --git a/core/internal/distros/repos_test.go b/core/internal/distros/repos_test.go new file mode 100644 index 000000000..35849f4b6 --- /dev/null +++ b/core/internal/distros/repos_test.go @@ -0,0 +1,424 @@ +package distros + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/duxlabs-dev/dux-shell/core/internal/deps" +) + +func TestOBSZypperAlias(t *testing.T) { + if got := obsZypperAlias("home:AvengeMedia:dms"); got != "home_AvengeMedia_dms" { + t.Fatalf("alias = %q, want the section header spelling home_AvengeMedia_dms", got) + } +} + +func TestCOPRFromRepoMatches(t *testing.T) { + cases := []struct { + fromRepo, repo string + want bool + }{ + {"copr:copr.fedorainfracloud.org:duxlabs:dms-git\n", coprDMSGit, true}, + {"copr:copr.fedorainfracloud.org:avengemedia:dms-git", coprDMSGit, true}, + {"copr:copr.fedorainfracloud.org:avengemedia:danklinux", coprDeps, true}, + {"copr:copr.fedorainfracloud.org:duxlabs:dms", coprDMSGit, false}, + {"copr:copr.fedorainfracloud.org:avengemedia:dms", coprDMSGit, false}, + {"copr:copr.fedorainfracloud.org:duxlabs:dms-git", coprDMS, false}, + {"copr:copr.fedorainfracloud.org:yalter:niri-git", "yalter/niri-git", true}, + } + for _, tc := range cases { + if got := coprFromRepoMatches(tc.fromRepo, tc.repo); got != tc.want { + t.Errorf("coprFromRepoMatches(%q, %q) = %v, want %v", tc.fromRepo, tc.repo, got, tc.want) + } + } +} + +func existsIn(paths ...string) func(string) bool { + return func(p string) bool { return slices.Contains(paths, p) } +} + +func TestPlanRepoMoves(t *testing.T) { + legacyDeps := coprRepoFile("avengemedia/danklinux") + retiredDeps := coprRepoFile("duxlabs/dux") + newDeps := coprRepoFile("duxlabs/dux-repo") + cases := []struct { + name string + exists func(string) bool + wantAdds []string + wantRemoves []string + wantLegacyIdx []string + }{ + {"legacy only", existsIn(legacyDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux"}, []string{legacyDeps}}, + {"both present", existsIn(legacyDeps, newDeps), nil, []string{"avengemedia/danklinux"}, []string{legacyDeps}}, + {"new only", existsIn(newDeps), nil, nil, nil}, + {"none", existsIn(), nil, nil, nil}, + {"retired dux slug", existsIn(retiredDeps), []string{"duxlabs/dux-repo"}, []string{"duxlabs/dux"}, []string{retiredDeps}}, + {"both legacy deps", existsIn(legacyDeps, retiredDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux", "duxlabs/dux"}, []string{legacyDeps, retiredDeps}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + moves := coprChannel.moves(tc.exists) + if len(moves) != 4 { + t.Fatalf("got %d rows, want dms, dms-git and the two legacy deps rows", len(moves)) + } + var adds, removes []string + for _, add := range repoMoveAdds(moves) { + adds = append(adds, add.RepoURL) + } + for _, move := range repoMoveRemoves(moves) { + removes = append(removes, move.Old) + } + if !slices.Equal(adds, tc.wantAdds) || !slices.Equal(removes, tc.wantRemoves) { + t.Fatalf("adds %v removes %v, want %v / %v", adds, removes, tc.wantAdds, tc.wantRemoves) + } + legacy := LegacyRepoPaths(moves) + if len(legacy) == 0 && len(tc.wantLegacyIdx) == 0 { + return + } + if !slices.Equal(legacy, tc.wantLegacyIdx) { + t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacyIdx) + } + }) + } +} + +func TestPlanRepoMovesPPAGlob(t *testing.T) { + moves := ppaChannel.moves(func(p string) bool { + return p == "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list" + }) + legacy := LegacyRepoPaths(moves) + if len(legacy) != 1 || legacy[0] != "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list" { + t.Fatalf("legacy = %v, want the 22.04 .list glob for dms only", legacy) + } +} + +// filepath.Glob over a fake listing, where dux-* also matches the dux-repo-* sibling. +func globIn(files ...string) func(string) []string { + return func(pattern string) []string { + var matches []string + for _, file := range files { + if ok, _ := filepath.Match(pattern, file); ok { + matches = append(matches, file) + } + } + return matches + } +} + +func TestUbuntuRepoMovesIgnoreRetiredSiblingGlob(t *testing.T) { + orig := repoGlob + repoGlob = globIn( + "/etc/apt/sources.list.d/duxlabs-ubuntu-dux-repo-resolute.sources", + "/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-resolute.sources") + t.Cleanup(func() { repoGlob = orig }) + + moves := NewUbuntuDistribution(Registry["ubuntu"], nil).RepoMoves() + want := []string{"/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources"} + if got := LegacyRepoPaths(moves); !slices.Equal(got, want) { + t.Fatalf("legacy = %v, want %v", got, want) + } + if adds := repoMoveAdds(moves); len(adds) != 0 { + t.Fatalf("planned adds %v while dux-repo is present", adds) + } +} + +func fakeXBPSConfs(t *testing.T, files map[string]string) { + t.Helper() + origGlob, origRead := repoGlob, repoReadFile + var paths []string + for path := range files { + paths = append(paths, path) + } + slices.Sort(paths) + repoGlob = globIn(paths...) + repoReadFile = func(path string) ([]byte, error) { + content, ok := files[path] + if !ok { + return nil, os.ErrNotExist + } + return []byte(content), nil + } + t.Cleanup(func() { repoGlob, repoReadFile = origGlob, origRead }) +} + +func TestVoidRepoMoves(t *testing.T) { + const ( + legacyDMS = "repository=https://void.danklinux.com/dms/current\n" + legacyDeps = "repository=https://void.danklinux.com/danklinux/current\n" + newDMS = "repository=https://void.duxlabs.dev/dms/current\n" + newDeps = "repository=https://void.duxlabs.dev/dux-repo/current\n" + ) + cases := []struct { + name string + files map[string]string + wantMoves []string + wantLegacy []string + wantNew bool + }{ + { + "both legacy", + map[string]string{"/etc/xbps.d/dms.conf": legacyDMS, "/etc/xbps.d/danklinux.conf": "# dms deps\n" + legacyDeps, "/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"}, + []string{ + "https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current", + "https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current", + }, + []string{"/etc/xbps.d/danklinux.conf", "/etc/xbps.d/dms.conf"}, + false, + }, + { + "one migrated", + map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": legacyDeps}, + []string{"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current"}, + []string{"/etc/xbps.d/dux.conf"}, + true, + }, + { + "both lines in one file", + map[string]string{"/etc/xbps.d/custom.conf": legacyDMS + legacyDeps}, + []string{ + "https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current", + "https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current", + }, + []string{"/etc/xbps.d/custom.conf"}, + false, + }, + {"greeter only", map[string]string{"/etc/xbps.d/dms.conf": legacyDMS}, []string{"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current"}, []string{"/etc/xbps.d/dms.conf"}, false}, + {"all migrated", map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": newDeps}, nil, nil, true}, + {"none", map[string]string{"/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"}, nil, nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeXBPSConfs(t, tc.files) + v := NewVoidDistribution(Registry["void"], nil) + moves, _ := PlanRepoMoves(v) + var got []string + newPresent := false + for _, move := range moves { + newPresent = newPresent || move.NewPresent + if move.OldPresent { + if move.NewPath != move.OldPath { + t.Fatalf("%s must be edited in place, got NewPath %s", move.OldPath, move.NewPath) + } + got = append(got, move.Old+" -> "+move.New) + } + } + if !slices.Equal(got, tc.wantMoves) { + t.Fatalf("moves %q, want %q", got, tc.wantMoves) + } + if legacy := LegacyRepoPaths(moves); !slices.Equal(legacy, tc.wantLegacy) && len(legacy)+len(tc.wantLegacy) > 0 { + t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacy) + } + if newPresent != tc.wantNew { + t.Fatalf("new repo present = %v, want %v", newPresent, tc.wantNew) + } + if commands := v.RepoMoveCommands(moves); (len(commands) == 0) != (len(tc.wantMoves) == 0) { + t.Fatalf("commands %q for moves %q", commands, tc.wantMoves) + } + }) + } +} + +func TestVoidMappingsUseNewHost(t *testing.T) { + v := NewVoidDistribution(Registry["void"], nil) + for _, variants := range []map[string]deps.PackageVariant{ + {"dms (Dux Shell)": deps.VariantStable, "dms-greeter": deps.VariantStable}, + {"dms (Dux Shell)": deps.VariantGit, "dms-greeter": deps.VariantGit}, + } { + packages := v.GetPackageMappingWithVariants(deps.WindowManagerNiri, variants) + want := map[string]string{ + "dms (Dux Shell)": "https://void.duxlabs.dev/dms/current", + "dms-greeter": "https://void.duxlabs.dev/dux-repo/current", + "danksearch": "https://void.duxlabs.dev/dux-repo/current", + "dankcalendar": "https://void.duxlabs.dev/dux-repo/current", + } + for dep, url := range want { + if got := packages[dep].RepoURL; got != url { + t.Errorf("%s (%s) RepoURL = %q, want %q", dep, packages[dep].Name, got, url) + } + } + } +} + +func TestRepoMoveCommands(t *testing.T) { + withOrig := func(c repoChannel, present ...string) []RepoMove { + return c.moves(existsIn(present...)) + } + + t.Run("fedora", func(t *testing.T) { + f := NewFedoraDistribution(Registry["fedora"], nil) + moves := withOrig(coprChannel, coprRepoFile("avengemedia/dms"), coprRepoFile("avengemedia/danklinux"), coprRepoFile("duxlabs/dms")) + want := []string{ + "sudo dnf copr enable -y duxlabs/dux-repo", + "sudo dnf copr remove -y avengemedia/dms", + "sudo dnf copr remove -y avengemedia/danklinux", + } + if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("fedora image-based", func(t *testing.T) { + f := NewFedoraDistribution(Registry["fedora"], nil) + moves := withOrig(coprChannel, coprRepoFile("avengemedia/danklinux")) + want := []string{ + "sudo curl -fsSLo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dux-repo.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dux-repo/repo/fedora-$(rpm -E %fedora)/duxlabs-dux-repo-fedora-$(rpm -E %fedora).repo", + "sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo", + } + if got := f.ImageRepoMoveCommands(moves); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("fedora retired dux slug", func(t *testing.T) { + f := NewFedoraDistribution(Registry["fedora"], nil) + moves := withOrig(coprChannel, coprRepoFile("duxlabs/dux")) + want := []string{ + "sudo dnf copr enable -y duxlabs/dux-repo", + "sudo dnf copr remove -y duxlabs/dux", + } + if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("ubuntu", func(t *testing.T) { + u := NewUbuntuDistribution(Registry["ubuntu"], nil) + moves := withOrig(ppaChannel, "/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources", "/etc/apt/sources.list.d/duxlabs-ubuntu-dux-*.sources") + want := []string{ + "sudo add-apt-repository -y ppa:duxlabs/dux-repo", + "sudo add-apt-repository -r -y ppa:avengemedia/danklinux", + "sudo add-apt-repository -r -y ppa:duxlabs/dux", + "sudo apt-get update", + } + if got := u.RepoMoveCommands(moves); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("debian", func(t *testing.T) { + moves := withOrig(obsAptChannel, obsAptListFile("home:AvengeMedia:dms-git")) + want := []string{ + "curl -fsSL https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/keyrings/home-duxlabs-dms-git.gpg", + "echo 'deb [signed-by=/etc/apt/keyrings/home-duxlabs-dms-git.gpg arch=amd64] https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/ /' | sudo tee /etc/apt/sources.list.d/home-duxlabs-dms-git.list", + "sudo rm -f /etc/apt/sources.list.d/home-AvengeMedia-dms-git.list /etc/apt/keyrings/home-AvengeMedia-dms-git.gpg", + "sudo apt-get update", + } + if got := debianRepoMoveCommands(moves, "Debian_13", "amd64"); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("void", func(t *testing.T) { + fakeXBPSConfs(t, map[string]string{ + "/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n", + "/etc/xbps.d/dux.conf": "repository=https://void.danklinux.com/danklinux/current\n", + }) + v := NewVoidDistribution(Registry["void"], nil) + want := []string{ + "sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf", + "sudo sed -i 's|repository=https://void.danklinux.com/danklinux/current|repository=https://void.duxlabs.dev/dux-repo/current|' /etc/xbps.d/dux.conf", + "sudo xbps-install -S", + } + if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("void greeter only", func(t *testing.T) { + fakeXBPSConfs(t, map[string]string{"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n"}) + v := NewVoidDistribution(Registry["void"], nil) + want := []string{ + "sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf", + "sudo sh -c 'echo repository=https://void.duxlabs.dev/dux-repo/current >> /etc/xbps.d/dux.conf'", + "sudo xbps-install -S", + } + if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) + + // Either spelling of the deps line already serves the greeter, so no second dux.conf entry. + for name, deps := range map[string]string{ + "legacy": "repository=https://void.danklinux.com/danklinux/current\n", + "new": "repository=https://void.duxlabs.dev/dux-repo/current\n", + } { + t.Run("void deps already "+name, func(t *testing.T) { + fakeXBPSConfs(t, map[string]string{ + "/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n", + "/etc/xbps.d/danklinux.conf": deps, + }) + v := NewVoidDistribution(Registry["void"], nil) + for _, command := range v.RepoMoveCommands(v.RepoMoves()) { + if strings.Contains(command, "dux.conf") { + t.Fatalf("added a duplicate deps repo: %q", command) + } + } + }) + } + + t.Run("opensuse", func(t *testing.T) { + moves := withOrig(obsZypperChannel, obsZypperRepoFile("home:AvengeMedia:danklinux")) + want := []string{ + "sudo zypper addrepo -f https://download.opensuse.org/repositories/home:/duxlabs:/dux-repo/openSUSE_Tumbleweed/home:duxlabs:dux-repo.repo", + "sudo zypper --gpg-auto-import-keys refresh", + "sudo zypper removerepo home_AvengeMedia_danklinux", + } + if got := opensuseRepoMoveCommands(moves, "openSUSE_Tumbleweed"); !slices.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + }) +} + +// Until OBS publishes, movers must plan nothing even with legacy files present, or doctor and migrate nag. +func TestOBSMigrationGate(t *testing.T) { + if obsMigrationReady { + t.Skip("gate open") + } + orig := repoPathExists + repoPathExists = func(string) bool { return true } + t.Cleanup(func() { repoPathExists = orig }) + + if moves := NewDebianDistribution(Registry["debian"], nil).RepoMoves(); moves != nil { + t.Fatalf("debian planned %v while the gate is closed", moves) + } + if moves := NewOpenSUSEDistribution(Registry["opensuse-tumbleweed"], nil).RepoMoves(); moves != nil { + t.Fatalf("opensuse planned %v while the gate is closed", moves) + } + if got := NewDebianDistribution(Registry["debian"], nil).GetPackageMapping(0)["dms-greeter"].RepoURL; got != "home:AvengeMedia:danklinux" { + t.Fatalf("debian mapping = %q, must stay on the legacy OBS project while the gate is closed", got) + } +} + +func TestSwapVariantsDisablesLegacyCOPR(t *testing.T) { + var commands []string + origExec, origExists := privescExec, repoPathExists + privescExec = func(_ context.Context, _, command string) *exec.Cmd { + commands = append(commands, command) + return exec.Command("true") + } + repoPathExists = existsIn(coprRepoFile("avengemedia/dms")) + t.Cleanup(func() { privescExec, repoPathExists = origExec, origExists }) + + f := NewFedoraDistribution(Registry["fedora"], nil) + swaps := []VariantSwap{{Dep: "dms (Dux Shell)", From: f.getDmsMapping(0), To: f.getDmsMapping(1)}} + progress := make(chan InstallProgressMsg, 100) + if err := f.SwapVariants(context.Background(), swaps, "", progress); err != nil { + t.Fatal(err) + } + + joined := strings.Join(commands, "\n") + if !strings.Contains(joined, "dnf copr disable -y avengemedia/dms") { + t.Fatalf("legacy COPR left enabled on an un-migrated host:\n%s", joined) + } + if strings.Contains(joined, "dnf copr disable -y duxlabs/dms") { + t.Fatalf("disabled a COPR whose file does not exist:\n%s", joined) + } + if !strings.Contains(joined, "dnf copr enable -y duxlabs/dms-git") { + t.Fatalf("new git COPR not enabled:\n%s", joined) + } +} diff --git a/core/internal/distros/ubuntu.go b/core/internal/distros/ubuntu.go index ea9baef87..4e7edf062 100644 --- a/core/internal/distros/ubuntu.go +++ b/core/internal/distros/ubuntu.go @@ -129,6 +129,51 @@ func (u *UbuntuDistribution) SwapVariants(ctx context.Context, swaps []VariantSw return u.installAPTPackages(ctx, to, sudoPassword, progressChan) } +func (u *UbuntuDistribution) RepoMoves() []RepoMove { + return ppaChannel.moves(ppaSourcesExists) +} + +func (u *UbuntuDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if adds := repoMoveAdds(moves); len(adds) > 0 { + if err := u.enablePPARepos(ctx, adds, sudoPassword, progressChan); err != nil { + return err + } + } + removes := repoMoveRemoves(moves) + for _, move := range removes { + command := "add-apt-repository -r -y " + move.Old + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.30, + Step: fmt.Sprintf("Removing PPA %s...", move.Old), + NeedsSudo: true, + CommandInfo: "sudo " + command, + } + if err := u.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil { + return fmt.Errorf("failed to remove PPA %s: %w", move.Old, err) + } + } + if len(removes) == 0 { + return nil + } + return u.aptUpdate(ctx, sudoPassword, progressChan) +} + +func (u *UbuntuDistribution) RepoMoveCommands(moves []RepoMove) []string { + var commands []string + for _, add := range repoMoveAdds(moves) { + commands = append(commands, "sudo add-apt-repository -y "+add.RepoURL) + } + removes := repoMoveRemoves(moves) + for _, move := range removes { + commands = append(commands, "sudo add-apt-repository -r -y "+move.Old) + } + if len(removes) > 0 { + commands = append(commands, "sudo apt-get update") + } + return commands +} + func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) } @@ -145,11 +190,11 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager // DMS packages from PPAs "dms (Dux Shell)": u.getDmsMapping(variants["dms (Dux Shell)"]), "quickshell": u.getQuickshellMapping(variants["quickshell"]), - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, "ppa:avengemedia/danklinux"), - "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, - "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, - "danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, - "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"), + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, ppaDeps), + "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: ppaDeps}, + "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: ppaDeps}, + "danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: ppaDeps}, + "dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, ppaDeps), } switch wm { @@ -170,30 +215,30 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms-git"} + return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: ppaDMSGit} } - return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms"} + return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: ppaDMS} } func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { if forceQuickshellGit || variant == deps.VariantGit { - return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: ppaDeps} } - return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: ppaDeps} } func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: ppaDeps} } - return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: ppaDeps} } func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { if variant == deps.VariantGit { - return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: ppaDeps} } - return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: ppaDeps} } func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { diff --git a/core/internal/distros/variant_swap_test.go b/core/internal/distros/variant_swap_test.go index 9965eee00..805f648c0 100644 --- a/core/internal/distros/variant_swap_test.go +++ b/core/internal/distros/variant_swap_test.go @@ -15,7 +15,7 @@ type swapFake struct { func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { return map[string]PackageMapping{ - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"), + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "duxlabs/dux-repo"), "matugen": {Name: "matugen", Repository: RepoTypeSystem}, } } diff --git a/core/internal/distros/void.go b/core/internal/distros/void.go index e7cd9083a..2de44175c 100644 --- a/core/internal/distros/void.go +++ b/core/internal/distros/void.go @@ -10,13 +10,14 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/duxlabs-dev/dux-shell/core/internal/privesc" - "github.com/duxlabs-dev/dux-shell/core/internal/site" ) const ( - VoidDMSRepo = site.VoidRepo + "/dms/current" - VoidDankLinuxRepo = site.VoidRepo + "/danklinux/current" - VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64" + VoidDMSRepo = voidHost + "/" + projectDMS + "/current" + VoidDepsRepo = voidHost + "/" + projectDeps + "/current" + VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64" + + voidDepsConf = "/etc/xbps.d/dux.conf" voidRunitSvDir = "/etc/sv" voidRunitServiceDir = "/var/service" @@ -193,6 +194,103 @@ func (v *VoidDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr) } +func (v *VoidDistribution) RepoMoves() []RepoMove { + var moves []RepoMove + legacy, hasDeps := false, false + for _, path := range repoGlob(voidConfGlob) { + for _, url := range xbpsRepositoryLines(path) { + move, ok := voidRepoMove(path, url) + if !ok { + continue + } + legacy = legacy || move.OldPresent + hasDeps = hasDeps || strings.HasPrefix(move.New, voidHost+"/"+projectDeps+"/") + moves = append(moves, move) + } + } + // Greeter-only hosts got dms-greeter from dms/current, which no longer carries it. + if legacy && !hasDeps { + moves = append(moves, RepoMove{Kind: RepoTypeXBPS, New: VoidDepsRepo, NewPath: voidDepsConf}) + } + return moves +} + +// Matches on the repository line because older installs named the conf files differently. +func xbpsRepositoryLines(path string) []string { + content, err := repoReadFile(path) + if err != nil { + return nil + } + var urls []string + for line := range strings.Lines(string(content)) { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || strings.TrimSpace(key) != "repository" { + continue + } + urls = append(urls, strings.TrimSpace(value)) + } + return urls +} + +func voidRepoMove(path, url string) (RepoMove, bool) { + if strings.HasPrefix(url, voidHost+"/") { + return RepoMove{Kind: RepoTypeXBPS, New: url, NewPath: path, NewPresent: true}, true + } + rest, ok := strings.CutPrefix(url, legacyVoidHost+"/") + if !ok { + return RepoMove{}, false + } + if deps, ok := strings.CutPrefix(rest, legacyProjectDeps+"/"); ok { + rest = projectDeps + "/" + deps + } + return RepoMove{Kind: RepoTypeXBPS, Old: url, New: voidHost + "/" + rest, OldPath: path, NewPath: path, OldPresent: true}, true +} + +func voidAddsRepo(move RepoMove) bool { + return !move.OldPresent && !move.NewPresent && move.NewPath != "" +} + +func voidMoveCommands(moves []RepoMove) []string { + var commands []string + for _, move := range moves { + switch { + case move.OldPresent: + commands = append(commands, fmt.Sprintf("sed -i 's|repository=%s|repository=%s|' %s", move.Old, move.New, move.OldPath)) + case voidAddsRepo(move): + // Appends so an unrelated file that happens to share the name is not clobbered. + commands = append(commands, fmt.Sprintf("sh -c 'echo repository=%s >> %s'", move.New, move.NewPath)) + } + } + if len(commands) > 0 { + commands = append(commands, "xbps-install -S") + } + return commands +} + +func (v *VoidDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + for _, command := range voidMoveCommands(moves) { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.30, + Step: "Moving XBPS repositories...", + NeedsSudo: true, + CommandInfo: "sudo " + command, + } + if err := v.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.40); err != nil { + return fmt.Errorf("failed to run %s: %w", command, err) + } + } + return nil +} + +func (v *VoidDistribution) RepoMoveCommands(moves []RepoMove) []string { + var commands []string + for _, command := range voidMoveCommands(moves) { + commands = append(commands, "sudo "+command) + } + return commands +} + func (v *VoidDistribution) packageVersion(pkg string) string { output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output() if err != nil { @@ -220,9 +318,9 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, "quickshell": {Name: "quickshell", Repository: RepoTypeSystem}, "matugen": {Name: "matugen", Repository: RepoTypeSystem}, "dms (Dux Shell)": v.getDmsMapping(variants["dms (Dux Shell)"]), - "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDMSRepo), - "danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, - "dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, + "dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDepsRepo), + "danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo}, + "dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo}, } switch wm { @@ -521,8 +619,8 @@ func (v *VoidDistribution) xbpsRepoName(repoURL string) string { switch repoURL { case VoidDMSRepo: return "dms" - case VoidDankLinuxRepo: - return "danklinux" + case VoidDepsRepo: + return "dux" case VoidHyprlandRepo: return "hyprland" default: diff --git a/core/internal/server/sysupdate/handlers.go b/core/internal/server/sysupdate/handlers.go index 1ade320fd..82b0da5fe 100644 --- a/core/internal/server/sysupdate/handlers.go +++ b/core/internal/server/sysupdate/handlers.go @@ -5,6 +5,7 @@ import ( "github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc/params" + "github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/duxlabs-dev/dux-shell/core/internal/server/models" ) @@ -31,6 +32,8 @@ func HandleRequest(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m case "sysupdate.releases": force := params.BoolOpt(req.Params, "force", false) conn.Respond(req.ID, m.Releases(force)) + case "sysupdate.repoStatus": + conn.Respond(req.ID, hostRepoStatus()) case "sysupdate.setInterval": seconds, err := params.Int(req.Params, "seconds") if err != nil { @@ -62,6 +65,39 @@ func handleUpgrade(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { conn.Respond(req.ID, m.GetState()) } +type RepoStatus struct { + Legacy []string `json:"legacy"` + Command string `json:"command"` +} + +const repoMigrateCommand = "dms migrate dux" + +// Per call and file stats only: the shell asks once per connect. +func hostRepoStatus() RepoStatus { + osInfo, err := distros.GetOSInfo() + if err != nil { + return repoStatus(nil) + } + distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil) + if err != nil { + return repoStatus(nil) + } + return repoStatus(distro) +} + +func repoStatus(distro distros.Distribution) RepoStatus { + status := RepoStatus{Legacy: []string{}, Command: repoMigrateCommand} + if distro == nil { + return status + } + moves, ok := distros.PlanRepoMoves(distro) + if !ok { + return status + } + status.Legacy = distros.LegacyRepoPaths(moves) + return status +} + func stringSliceOpt(p map[string]any, key string) []string { val, ok := params.Any(p, key) if !ok { diff --git a/core/internal/server/sysupdate/repo_status_test.go b/core/internal/server/sysupdate/repo_status_test.go new file mode 100644 index 000000000..4c90fbd66 --- /dev/null +++ b/core/internal/server/sysupdate/repo_status_test.go @@ -0,0 +1,61 @@ +package sysupdate + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/duxlabs-dev/dux-go/ipc" + "github.com/duxlabs-dev/dux-shell/core/internal/distros" +) + +type repoMoverFake struct { + distros.Distribution + moves []distros.RepoMove +} + +func (f *repoMoverFake) RepoMoves() []distros.RepoMove { return f.moves } +func (f *repoMoverFake) ApplyRepoMoves(context.Context, []distros.RepoMove, string, chan<- distros.InstallProgressMsg) error { + return nil +} +func (f *repoMoverFake) RepoMoveCommands([]distros.RepoMove) []string { return nil } + +type plainDistroFake struct{ distros.Distribution } + +func TestRepoStatus(t *testing.T) { + const legacyPath = "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo" + mover := &repoMoverFake{moves: []distros.RepoMove{ + {Kind: distros.RepoTypeCOPR, Old: "avengemedia/danklinux", New: "duxlabs/dux-repo", OldPath: legacyPath, OldPresent: true}, + {Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", NewPresent: true}, + }} + got, _ := json.Marshal(repoStatus(mover)) + want := `{"legacy":["` + legacyPath + `"],"command":"dms migrate dux"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } + + for name, distro := range map[string]distros.Distribution{"no mover": &plainDistroFake{}, "nil": nil} { + got, _ := json.Marshal(repoStatus(distro)) + if string(got) != `{"legacy":[],"command":"dms migrate dux"}` { + t.Fatalf("%s: got %s, want an empty list, never null", name, got) + } + } +} + +// The route needs no manager: a shell on a host without a mover must still get an empty list. +func TestRepoStatusRoute(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go func() { + HandleRequest(context.Background(), ipc.NewConnWriter(server), ipc.Request{ID: 7, Method: "sysupdate.repoStatus"}, nil) + server.Close() + }() + var resp ipc.Response[RepoStatus] + if err := json.NewDecoder(client).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.ID != 7 || resp.Error != "" || resp.Result == nil || resp.Result.Command != "dms migrate dux" || resp.Result.Legacy == nil { + t.Fatalf("got %+v", resp) + } +} diff --git a/core/internal/site/site.go b/core/internal/site/site.go index 619ea49ae..42bfa4ce4 100644 --- a/core/internal/site/site.go +++ b/core/internal/site/site.go @@ -4,6 +4,6 @@ const ( Domain = "duxlabs.dev" Web = "https://" + Domain Docs = Web + "/docs" - API = "https://api.danklinux.com" - VoidRepo = "https://void.danklinux.com" + API = "https://api.duxlabs.dev" + VoidRepo = "https://void.duxlabs.dev" ) diff --git a/distro/fedora/dms.spec b/distro/fedora/dms.spec index 26e1b6deb..02db9e738 100644 --- a/distro/fedora/dms.spec +++ b/distro/fedora/dms.spec @@ -66,7 +66,7 @@ case "%{_arch}" in esac # Download dms-cli for target architecture -wget -O %{_builddir}/dms-cli.gz "https://github.com/duxlabs-dev/dux-shell/releases/latest/download/dms-distropkg-${ARCH_SUFFIX}.gz" || { +wget -O %{_builddir}/dms-cli.gz "https://github.com/duxlabs-dev/dux-shell/releases/download/v%{version}/dms-distropkg-${ARCH_SUFFIX}.gz" || { echo "Failed to download dms-cli for architecture %{_arch}" exit 1 } diff --git a/distro/scripts/copr-upload.sh b/distro/scripts/copr-upload.sh index 001f36c6e..91d8bb2d2 100755 --- a/distro/scripts/copr-upload.sh +++ b/distro/scripts/copr-upload.sh @@ -10,12 +10,13 @@ PACKAGE="${1:-dms}" VERSION="${2:-}" RELEASE="${3:-1}" +COPR_OWNER="${COPR_OWNER:-duxlabs}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # Determine Copr project based on package if [ "$PACKAGE" = "dms" ]; then - COPR_PROJECT="avengemedia/dms" + COPR_PROJECT="${COPR_OWNER}/dms" else echo "❌ Unknown package: $PACKAGE" echo "Supported packages: dms" @@ -25,7 +26,7 @@ fi # Get version from latest release if not provided if [ -z "$VERSION" ]; then echo "📦 Determining latest version..." - VERSION=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | jq -r '.tag_name' | sed 's/^v//') + VERSION=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | jq -r '.tag_name' | sed 's/^v//') if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then echo "❌ Failed to determine version. Please specify manually." exit 1 diff --git a/distro/scripts/obs-status.sh b/distro/scripts/obs-status.sh index 29439024c..66d91e8cb 100755 --- a/distro/scripts/obs-status.sh +++ b/distro/scripts/obs-status.sh @@ -8,7 +8,7 @@ # ./distro/scripts/obs-status.sh # Check all packages # ./distro/scripts/obs-status.sh dms # Check specific package -OBS_BASE_PROJECT="home:AvengeMedia" +OBS_BASE_PROJECT="${OBS_BASE_PROJECT:-home:AvengeMedia}" OBS_BASE="$HOME/.cache/osc-checkouts" ALL_PACKAGES=(dms dms-git) diff --git a/distro/scripts/obs-upload.sh b/distro/scripts/obs-upload.sh index 0111f0f9b..6cf44ea01 100755 --- a/distro/scripts/obs-upload.sh +++ b/distro/scripts/obs-upload.sh @@ -66,7 +66,7 @@ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then fi fi -OBS_BASE_PROJECT="home:AvengeMedia" +OBS_BASE_PROJECT="${OBS_BASE_PROJECT:-home:AvengeMedia}" OBS_BASE="$HOME/.cache/osc-checkouts" AVAILABLE_PACKAGES=(dms dms-git) diff --git a/distro/scripts/ppa-build.sh b/distro/scripts/ppa-build.sh index 66de413e2..9f3d90e22 100755 --- a/distro/scripts/ppa-build.sh +++ b/distro/scripts/ppa-build.sh @@ -10,6 +10,8 @@ set -e +PPA_OWNER="${PPA_OWNER:-duxlabs}" + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -152,7 +154,7 @@ check_ppa_version_exists() { local DISTRO_SERIES="${5:-}" # Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to resolute and stonking) - local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published" + local API_URL="https://api.launchpad.net/1.0/~${PPA_OWNER}/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published" if [[ -n "$DISTRO_SERIES" ]]; then API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}" fi @@ -595,7 +597,8 @@ else fi # -d skips dependency checking (we're building on Fedora, not Ubuntu) -if yes | DEBIAN_FRONTEND=noninteractive debuild -S $DEBUILD_SOURCE_FLAG -d; then +# -k: debsign otherwise picks the key from the changelog maintainer, which need not match the CI key +if yes | DEBIAN_FRONTEND=noninteractive debuild -S $DEBUILD_SOURCE_FLAG -d ${DEBSIGN_KEYID:+-k"$DEBSIGN_KEYID"}; then echo success "Source package built successfully!" @@ -617,10 +620,10 @@ if yes | DEBIAN_FRONTEND=noninteractive debuild -S $DEBUILD_SOURCE_FLAG -d; then echo " ls -lh ${SOURCE_NAME}_${CHANGELOG_VERSION}*" echo echo " 2. Upload to PPA (stable):" - echo " dput ppa:avengemedia/dms ${SOURCE_NAME}_${CHANGELOG_VERSION}_source.changes" + echo " dput ppa:${PPA_OWNER}/dms ${SOURCE_NAME}_${CHANGELOG_VERSION}_source.changes" echo echo " 3. Or upload to PPA (nightly):" - echo " dput ppa:avengemedia/dms-git ${SOURCE_NAME}_${CHANGELOG_VERSION}_source.changes" + echo " dput ppa:${PPA_OWNER}/dms-git ${SOURCE_NAME}_${CHANGELOG_VERSION}_source.changes" echo echo " 4. Or use the upload script:" echo " ./upload-ppa.sh $PACKAGE_PARENT/${SOURCE_NAME}_${CHANGELOG_VERSION}_source.changes dms" diff --git a/distro/scripts/ppa-status.sh b/distro/scripts/ppa-status.sh index 8cf4bab3f..58ca92d39 100755 --- a/distro/scripts/ppa-status.sh +++ b/distro/scripts/ppa-status.sh @@ -8,7 +8,7 @@ # ./distro/scripts/ppa-status.sh dms # Check dms package # ./distro/scripts/ppa-status.sh all dms-git # Check all packages in dms-git PPA -PPA_OWNER="avengemedia" +PPA_OWNER="${PPA_OWNER:-duxlabs}" LAUNCHPAD_API="https://api.launchpad.net/1.0" # Supported Ubuntu series for PPA builds (26.04 LTS resolute + 26.10 stonking) DISTRO_SERIES_LIST=(resolute stonking) diff --git a/distro/scripts/ppa-sync-plan.sh b/distro/scripts/ppa-sync-plan.sh index db68e0400..c4ad70519 100755 --- a/distro/scripts/ppa-sync-plan.sh +++ b/distro/scripts/ppa-sync-plan.sh @@ -3,7 +3,7 @@ set -euo pipefail -PPA_OWNER="avengemedia" +PPA_OWNER="${PPA_OWNER:-duxlabs}" LAUNCHPAD_API="https://api.launchpad.net/1.0" SERIES_LIST=(resolute stonking) PACKAGE_FILTER="dms-git" diff --git a/distro/scripts/ppa-upload.sh b/distro/scripts/ppa-upload.sh index fc031fac3..0e12fbe0b 100755 --- a/distro/scripts/ppa-upload.sh +++ b/distro/scripts/ppa-upload.sh @@ -28,6 +28,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } AVAILABLE_PACKAGES=(dms dms-git) +export PPA_OWNER="${PPA_OWNER:-duxlabs}" KEEP_BUILDS=false REBUILD_RELEASE="" @@ -217,16 +218,15 @@ PACKAGE_DIR=$(cd "$PACKAGE_DIR" && pwd) PARENT_DIR=$(dirname "$PACKAGE_DIR") setup_launchpad_sftp() { - if [[ -z "${LAUNCHPAD_SSH_PRIVATE_KEY:-}" ]]; then - error "LAUNCHPAD_SSH_PRIVATE_KEY is required for CI SFTP uploads." - error "Add a GitHub Actions secret containing a private SSH key whose public key is registered in Launchpad." - error "Optional: set LAUNCHPAD_SSH_LOGIN if the Launchpad login is not 'avengemedia'." + if [[ -z "${LAUNCHPAD_SSH_PRIVATE_KEY:-}" || -z "${LAUNCHPAD_SSH_LOGIN:-}" ]]; then + error "LAUNCHPAD_SSH_PRIVATE_KEY and LAUNCHPAD_SSH_LOGIN are required for CI SFTP uploads." + error "The key's public half must be registered on the Launchpad account named by LAUNCHPAD_SSH_LOGIN." exit 1 fi local ssh_dir="$HOME/.ssh" local key_file="$ssh_dir/launchpad_ppa" - local login="${LAUNCHPAD_SSH_LOGIN:-avengemedia}" + local login="$LAUNCHPAD_SSH_LOGIN" local strict_host_key_checking="yes" mkdir -p "$ssh_dir" @@ -296,7 +296,7 @@ UBUNTU_SERIES="${SERIES_LIST[0]}" info "Building and uploading: $PACKAGE_NAME" info "Package directory: $PACKAGE_DIR" -info "PPA: ppa:avengemedia/$PPA_NAME" +info "PPA: ppa:${PPA_OWNER}/$PPA_NAME" info "Ubuntu series: $UBUNTU_SERIES" if [[ -n "$REBUILD_RELEASE" ]]; then info "Rebuild release number: ppa$REBUILD_RELEASE" @@ -369,16 +369,16 @@ if [ "$PPA_NAME" = "dms" ] || [ "$PPA_NAME" = "dms-git" ]; then setup_launchpad_sftp DPUT_CONFIG=$(mktemp) cat >"$DPUT_CONFIG" <"$LFTP_SCRIPT" < **Repository migration:** the former GitHub Pages repositories will be > frozen for 14 days at cutover. Their retirement date will be announced when > the snapshots are frozen. Replace any existing `avengemedia.github.io` > entries with the URLs below. +> +> Existing installs: run `dms migrate dux`. #### 1. Add Repository Configurations Create configuration files in `/etc/xbps.d/` pointing to our repositories (needed for both stable and git/nightly variants): ```sh -echo "repository=https://void.danklinux.com/dms/current" | sudo tee /etc/xbps.d/dms.conf -echo "repository=https://void.danklinux.com/danklinux/current" | sudo tee /etc/xbps.d/danklinux.conf +echo "repository=https://void.duxlabs.dev/dms/current" | sudo tee /etc/xbps.d/dms.conf +echo "repository=https://void.duxlabs.dev/dux-repo/current" | sudo tee /etc/xbps.d/dux.conf ``` #### 2. Install DMS diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index bb3817a37..39120b1d5 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -44,6 +44,7 @@ Singleton { property string terminalOverride: "" property int updaterNotifiedUnix: 0 property int updaterNotifiedCount: 0 + property string repoMoveNoticeVersion: "" property bool isSwitchingMode: false property bool suppressOSD: true diff --git a/quickshell/Common/Site.qml b/quickshell/Common/Site.qml index bc24ce5f4..8be3862cd 100644 --- a/quickshell/Common/Site.qml +++ b/quickshell/Common/Site.qml @@ -6,7 +6,7 @@ Singleton { readonly property string domain: "duxlabs.dev" readonly property string web: "https://" + domain readonly property string docs: web + "/docs" - readonly property string api: "https://api.danklinux.com" + readonly property string api: "https://api.duxlabs.dev" readonly property string plugins: "https://plugins." + domain readonly property string wallpaperCreator: "https://wallpaper." + domain } diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index 3e1889443..1dc7cb950 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -30,6 +30,9 @@ var LOCAL_SPEC = { updaterNotifiedCount: { def: 0 }, + repoMoveNoticeVersion: { + def: "" + }, terminalOverride: { def: "" }, diff --git a/quickshell/Modules/Settings/SoftwareUpdatesTab.qml b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml index be23d5cff..3a1b3804a 100644 --- a/quickshell/Modules/Settings/SoftwareUpdatesTab.qml +++ b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import Quickshell import qs.Common import qs.Services import qs.DCommon.Widgets @@ -422,6 +423,27 @@ Item { iconColor: Theme.warning subtitle: SystemUpdateService.shellInstalled ? I18n.tr("Installed %1, still running %2", "%1 is the installed version, %2 the running one").arg(SystemUpdateService.shellInstalled).arg(SystemUpdateService.shellRunning) : I18n.tr("A newer dms binary is installed.", "software updates row subtitle, the running shell is older than the installed binary") } + + SettingsRow { + visible: SystemUpdateService.legacyRepos.length > 0 + title: I18n.tr("Package repositories moved", "notification title, the DMS package repositories moved to a new owner") + subtitle: I18n.tr("Run %1 in a terminal.", "%1 is a shell command").arg(SystemUpdateService.migrateCommand) + iconName: "swap_horiz" + iconColor: Theme.warning + + DActionButton { + anchors.verticalCenter: parent.verticalCenter + buttonSize: Theme.buttonHeightS + iconName: "content_copy" + iconColor: Theme.surfaceText + backgroundColor: SettingsMetrics.controlSurface + tooltipText: I18n.tr("Copy") + onClicked: { + Quickshell.execDetached(["dms", "cl", "copy", SystemUpdateService.migrateCommand]); + ToastService.showInfo(I18n.tr("Copied to clipboard")); + } + } + } } SettingsCard { diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 9d04e075c..5b6b14a52 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -823,4 +823,8 @@ Singleton { "force": force === true }, callback); } + + function sysupdateRepoStatus(callback) { + sendRequest("sysupdate.repoStatus", null, callback); + } } diff --git a/quickshell/Services/RepoMoveNotice.js b/quickshell/Services/RepoMoveNotice.js new file mode 100644 index 000000000..503cc9d38 --- /dev/null +++ b/quickshell/Services/RepoMoveNotice.js @@ -0,0 +1,6 @@ +.pragma library + +// Full version on purpose: every release re-arms the notice once. +function repoNoticeDue(legacy, seenVersion, currentVersion) { + return (legacy?.length ?? 0) > 0 && currentVersion !== "" && seenVersion !== currentVersion; +} diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index ff130dc94..8ec43ddb3 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -5,6 +5,7 @@ import QtQuick import Quickshell import qs.Common import qs.Services +import "RepoMoveNotice.js" as RepoMoveNotice Singleton { id: root @@ -61,6 +62,9 @@ Singleton { // -1 while unknown (stable channel or no feed yet) property int commitsBehind: -1 property var releases: null + // Repo files, not URLs: what `dms migrate dux` would move. + property var legacyRepos: [] + property string migrateCommand: "dms migrate dux" readonly property int updateCount: availableUpdates.length readonly property var systemUpdates: availableUpdates.filter(p => !shellUpdate || p.name !== shellUpdate.name) @@ -116,6 +120,20 @@ Singleton { } } + Connections { + target: SessionData + function onLoaded() { + root._maybeNotifyRepoMove(); + } + } + + Connections { + target: ShellVersionService + function onSemverVersionChanged() { + root._maybeNotifyRepoMove(); + } + } + Connections { target: SettingsData function onUpdaterCheckOnStartChanged() { @@ -151,6 +169,7 @@ Singleton { const has = DMSService.capabilities.includes("sysupdate"); if (has && !sysupdateAvailable) { sysupdateAvailable = true; + requestRepoStatus(); if (pollWanted) { requestState(); // The daemon persists its last check but not the interval; re-apply it on every fresh connection. @@ -173,6 +192,39 @@ Singleton { }); } + function requestRepoStatus() { + DMSService.sysupdateRepoStatus(resp => { + const result = resp?.result; + if (!result) + return; + root.legacyRepos = result.legacy || []; + root.migrateCommand = result.command || "dms migrate dux"; + root._maybeNotifyRepoMove(); + }); + } + + function _maybeNotifyRepoMove() { + if (_repoNoticeInFlight || !SessionData._hasLoaded) + return; + const current = ShellVersionService.semverVersion; + if (!RepoMoveNotice.repoNoticeDue(legacyRepos, SessionData.repoMoveNoticeVersion, current)) + return; + _repoNoticeInFlight = true; + DMSService.notifySend({ + "summary": I18n.tr("Package repositories moved", "notification title, the DMS package repositories moved to a new owner"), + "body": I18n.tr("Run %1 in a terminal.", "%1 is a shell command").arg(migrateCommand), + "actionLabel": I18n.tr("Settings"), + "actionArgs": ["ipc", "call", "settings", "openWith", "updater"] + }, resp => { + root._repoNoticeInFlight = false; + if (!resp || resp.error) + return; + SessionData.set("repoMoveNoticeVersion", current); + }); + } + + property bool _repoNoticeInFlight: false + function _applyState(data) { if (!data) { return; diff --git a/quickshell/tests/unit/tst_sysupdate_repo_notice.qml b/quickshell/tests/unit/tst_sysupdate_repo_notice.qml new file mode 100644 index 000000000..9c2b62d14 --- /dev/null +++ b/quickshell/tests/unit/tst_sysupdate_repo_notice.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtTest +import "../../Services/RepoMoveNotice.js" as RepoMoveNotice + +TestCase { + name: "SysupdateRepoNotice" + + readonly property var legacy: ["/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo"] + + function test_repoNoticeDue_data() { + return [ + { tag: "legacy on a new version", legacy: legacy, seen: "", current: "1.4.0", due: true }, + { tag: "release re-arms", legacy: legacy, seen: "1.4.0", current: "1.4.1", due: true }, + { tag: "already shown for this version", legacy: legacy, seen: "1.4.1", current: "1.4.1", due: false }, + { tag: "nothing to migrate", legacy: [], seen: "", current: "1.4.0", due: false }, + { tag: "version not read yet", legacy: legacy, seen: "1.4.0", current: "", due: false } + ]; + } + + function test_repoNoticeDue(row) { + compare(RepoMoveNotice.repoNoticeDue(row.legacy, row.seen, row.current), row.due); + } + +}