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
This commit is contained in:
purian23 committed 2026-10-30 05:39:22 -04:00
1 parent d367e7e71b
commit 095d1c6fd5
43 files changed
+1879 -421

No files matched your search

+58 -3
View File
@@ -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 <[email protected]>" --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
+1
View File
@@ -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/<repo>/<timestamp>/ snapshots written by
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
# <repo> comes from the listing, so new and legacy prefixes need no list.
on:
schedule:
+77 -121
View File
@@ -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"
+95 -98
View File
@@ -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
+36 -11
View File
@@ -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"
+7 -34
View File
@@ -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 <[email protected]>" --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
+1
View File
@@ -795,6 +795,7 @@ func getCommonCommands() []*cobra.Command {
systemdCmd,
switchUserCmd,
switchCmd,
migrateCmd,
dmenuCmd,
}...)
}
+31
View File
@@ -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
+8 -3
View File
@@ -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"
}
+117
View File
@@ -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)
}
}
+81
View File
@@ -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)
}
}
+23 -11
View File
@@ -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
}
+2 -2
View File
@@ -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,
}
+71 -18
View File
@@ -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")
+90 -33
View File
@@ -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 {
+2 -2
View File
@@ -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])
}
}
+5 -5
View File
@@ -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) {
+79 -25
View File
@@ -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_<owner>_<project>.
if repoPathExists(obsZypperRepoFile(pkg.RepoURL)) {
o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
continue
+257
View File
@@ -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 <owner>-ubuntu-<project>-<series>.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
}
+424
View File
@@ -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)
}
}
+58 -13
View File
@@ -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 {
+1 -1
View File
@@ -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},
}
}
+107 -9
View File
@@ -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:
@@ -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 {
@@ -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)
}
}
+2 -2
View File
@@ -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"
)
+1 -1
View File
@@ -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
}
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+7 -4
View File
@@ -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"
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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"
+12 -12
View File
@@ -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" <<EOF
[avengemedia-${PPA_NAME}]
[${PPA_OWNER}-${PPA_NAME}]
fqdn = ppa.launchpad.net
method = sftp
incoming = ~avengemedia/ubuntu/${PPA_NAME}/
login = ${LAUNCHPAD_SSH_LOGIN:-avengemedia}
incoming = ~${PPA_OWNER}/ubuntu/${PPA_NAME}/
login = ${LAUNCHPAD_SSH_LOGIN}
allow_unsigned_uploads = 0
EOF
info "Using dput for CI upload (SFTP)"
if dput -c "$DPUT_CONFIG" "avengemedia-${PPA_NAME}" "$CHANGES_FILE"; then
if dput -c "$DPUT_CONFIG" "${PPA_OWNER}-${PPA_NAME}" "$CHANGES_FILE"; then
success "Upload successful!"
rm -f "$DPUT_CONFIG"
else
@@ -389,7 +389,7 @@ EOF
else
LFTP_SCRIPT=$(mktemp)
cat >"$LFTP_SCRIPT" <<EOF
cd ~avengemedia/ubuntu/$PPA_NAME/
cd ~${PPA_OWNER}/ubuntu/$PPA_NAME/
lcd $BUILD_DIR
mput $CHANGES_BASENAME
mput $DSC_FILE
@@ -418,7 +418,7 @@ fi
echo
success "Package uploaded successfully!"
info "Monitor build progress at:"
echo " https://launchpad.net/~avengemedia/+archive/ubuntu/$PPA_NAME/+packages"
echo " https://launchpad.net/~${PPA_OWNER}/+archive/ubuntu/$PPA_NAME/+packages"
echo
if [ "$KEEP_BUILDS" = "false" ]; then
+9 -6
View File
@@ -5,32 +5,35 @@ XBPS templates for Dux Shell on [Void Linux](https://voidlinux.org).
| Package | Source repo | Template |
| --- | --- | --- |
| `dms` | Dux Shell | [`srcpkgs/dms/template`](srcpkgs/dms/template) |
| `dgop` | duxlabs-dev/dux-gop | maintained in the **danklinux** repo (`distro/void/srcpkgs/dgop`) |
| `danksearch` | duxlabs-dev/dux-search | maintained in the **danklinux** repo (`distro/void/srcpkgs/danksearch`) |
| `dgop` | duxlabs-dev/dux-gop | maintained in the **dux-repo** repo (`distro/void/srcpkgs/dgop`) |
| `danksearch` | duxlabs-dev/dux-search | maintained in the **dux-repo** repo (`distro/void/srcpkgs/danksearch`) |
All build from source.
## Distribution
This is a DMS maintained repo for VoidLinux until these packages are officially merged upstream in the Void Linux repositories, you can install them from our self-hosted custom XBPS repositories served via GitHub Pages.
This is a DMS maintained repo for VoidLinux until these packages are officially merged upstream in the Void Linux repositories, you can install them from our self-hosted custom XBPS repositories.
### Using the Self-Hosted Repositories
We serve both stable release and development packages from Cloudflare R2 at
`void.danklinux.com`.
`void.duxlabs.dev`: `dms` and `dms-git` from `dms/current`, their dependencies
and `dms-greeter` from `dux-repo/current`.
> **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
+1
View File
@@ -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
+1 -1
View File
@@ -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
}
@@ -30,6 +30,9 @@ var LOCAL_SPEC = {
updaterNotifiedCount: {
def: 0
},
repoMoveNoticeVersion: {
def: ""
},
terminalOverride: {
def: ""
},
@@ -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 {
+4
View File
@@ -823,4 +823,8 @@ Singleton {
"force": force === true
}, callback);
}
function sysupdateRepoStatus(callback) {
sendRequest("sysupdate.repoStatus", null, callback);
}
}
+6
View File
@@ -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;
}
@@ -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;
@@ -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);
}
}