diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 005f468..4a5c9ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,6 +24,7 @@ jobs: timeout-minutes: 5 outputs: sha: ${{ steps.commit.outputs.sha }} + version: ${{ steps.commit.outputs.version }} steps: # workflow_dispatch has a branch selector, but a Nightly run always means # develop. Resolve it once so every target builds the identical commit @@ -36,13 +37,22 @@ jobs: - name: Record source commit id: commit shell: bash - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + base=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + stamp=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y%m%d%H%M) + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$stamp" =~ ^[0-9]{12}$ ]] + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$base+git$stamp.${sha:0:7}" >> "$GITHUB_OUTPUT" binaries: name: ${{ matrix.target }} needs: source runs-on: ${{ matrix.os }} timeout-minutes: 45 + env: + SOULSEEK_RS_VERSION: ${{ needs.source.outputs.version }} strategy: fail-fast: false matrix: @@ -95,6 +105,12 @@ jobs: dry-run: true dry-run-intended: true + - name: Build .deb and .rpm + if: endsWith(matrix.target, '-musl') + env: + TARGET: ${{ matrix.target }} + run: sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$SOULSEEK_RS_VERSION" "$TARGET" . soulseek-rs-nightly + - name: Store packaged binary uses: actions/upload-artifact@v4 with: @@ -102,6 +118,8 @@ jobs: path: | ${{ steps.package.outputs.tar || steps.package.outputs.zip }} ${{ steps.package.outputs.sha256 }} + soulseek-rs-nightly-*.deb + soulseek-rs-nightly-*.rpm if-no-files-found: error retention-days: 1 @@ -137,6 +155,10 @@ jobs: soulseek-rs-nightly-aarch64-apple-darwin.sha256 soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + soulseek-rs-nightly-amd64.deb + soulseek-rs-nightly-arm64.deb + soulseek-rs-nightly-x86_64.rpm + soulseek-rs-nightly-aarch64.rpm ) printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" @@ -219,9 +241,12 @@ jobs: smoke-test: name: Install published nightly - needs: publish + needs: [source, publish] runs-on: ubuntu-latest timeout-minutes: 5 + env: + VERSION: ${{ needs.source.outputs.version }} + DOWNLOAD: https://github.com/${{ github.repository }}/releases/download/nightly steps: - uses: actions/checkout@v4 with: @@ -233,7 +258,7 @@ jobs: SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install run: | sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" - name: Install with wget and no curl on PATH env: @@ -246,4 +271,21 @@ jobs: ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" done PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly - "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version | grep -F "$VERSION" + + - name: Install the .deb + run: | + curl -fsSLO "$DOWNLOAD/soulseek-rs-nightly-amd64.deb" + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm fedora:latest sh -ec " + dnf install -y '$DOWNLOAD/soulseek-rs-nightly-x86_64.rpm' + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..bb1b304 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,87 @@ +name: Packaging + +on: + pull_request: + paths: + - 'flake.nix' + - 'flake.lock' + - 'Cargo.lock' + - 'Cargo.toml' + - 'packaging/**' + - 'scripts/package-linux.sh' + - 'soulseek-rs/src/cli.rs' + - 'soulseek-rs/src/run.rs' + - 'soulseek-rs/src/commands/**' + - '.github/workflows/packaging.yml' + +permissions: + contents: read + +concurrency: + group: packaging-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + VERSION: 0.0.0+git202601010000.0000000 + +jobs: + packages: + name: .deb and .rpm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build the packages + env: + SOULSEEK_RS_VERSION: ${{ env.VERSION }} + TARGET: x86_64-unknown-linux-musl + run: | + cargo build --locked --release --target "$TARGET" --package soulseek-rs + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "$VERSION" "$TARGET" . soulseek-rs-nightly + + - name: Install the .deb + run: | + sudo apt-get install -y ./soulseek-rs-nightly-amd64.deb + soulseek-rs --version | grep -F "$VERSION" + dpkg -L soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + dpkg -L soulseek-rs | grep -Fx /usr/share/zsh/vendor-completions/_soulseek-rs + + - name: Install the .rpm + run: | + docker run --rm -v "$PWD:/packages:ro" fedora:latest sh -ec " + dnf install -y /packages/soulseek-rs-nightly-x86_64.rpm + soulseek-rs --version | grep -F '$VERSION' + rpm -ql soulseek-rs | grep -Fx /usr/share/man/man1/soulseek-rs.1.gz + rpm -ql soulseek-rs | grep -Fx /usr/share/zsh/site-functions/_soulseek-rs + " + + flake: + name: Nix flake + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: cachix/install-nix-action@v31 + + - name: Build and run + run: | + nix build --print-build-logs + ./result/bin/soulseek-rs --version | grep -E '\+git[0-9]{12}\.[0-9a-f]{7}$' + test -s result/share/man/man1/soulseek-rs.1.gz + test -s result/share/fish/vendor_completions.d/soulseek-rs.fish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac7f993..a7bbc65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,6 +200,16 @@ jobs: ref: refs/tags/${{ needs.release.outputs.tag }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and upload .deb and .rpm + if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist + gh release upload "$TAG" dist/* --clobber + # The tap regenerates its formula from whatever `gh release view` calls latest, # so it only needs a nudge once every archive and .sha256 is on the release — # hence `needs: binaries` rather than firing alongside it. The tap also polls diff --git a/Cargo.lock b/Cargo.lock index e316a04..d57a443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -508,14 +508,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] @@ -524,6 +524,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -2251,6 +2261,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2550,6 +2566,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "color-eyre", "directories", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index 310dc01..47a88b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" version = "19.0.0" authors = ["Michel de Graaf"] edition = "2024" +rust-version = "1.91" license = "MIT" repository = "https://github.com/michel/soulseek-rs" homepage = "https://re-invention.nl/soulseek-rs/" diff --git a/README.md b/README.md index db9eb9d..bf7808b 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,66 @@ numbered release. A failed download, checksum, or startup check leaves an existing binary untouched. Windows archives for both channels are on the [releases page](https://github.com/michel/soulseek-rs/releases). -The other stable routes need no installer script: +Or install it with a package manager. Stable follows the latest release: ```bash -brew install michel/tap/soulseek-rs # macOS and Linux -cargo install soulseek-rs # or from crates.io +# Debian, Ubuntu, Mint (on ARM: arm64 for amd64) +curl -fsSLO https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-amd64.deb +sudo apt install ./soulseek-rs-amd64.deb + +# Fedora, RHEL (on ARM: aarch64 for x86_64) +sudo dnf install https://github.com/michel/soulseek-rs/releases/latest/download/soulseek-rs-x86_64.rpm + +# Nix +nix run github:michel/soulseek-rs/master + +# Homebrew, on macOS and Linux +brew install michel/tap/soulseek-rs + +# cargo-binstall fetches the release binary and compiles nothing +cargo binstall soulseek-rs + +# From source: Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one +cargo install --locked soulseek-rs ``` +Nightly is the tip of `develop`. The `.deb` and `.rpm` are downloads; Nix, +Homebrew and cargo build it from source: + +```bash +curl -fsSLO https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-amd64.deb +sudo apt install ./soulseek-rs-nightly-amd64.deb + +sudo dnf install https://github.com/michel/soulseek-rs/releases/download/nightly/soulseek-rs-nightly-x86_64.rpm + +nix run github:michel/soulseek-rs/develop + +brew install --HEAD michel/tap/soulseek-rs + +cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs +``` + +A `.deb` or `.rpm` does not update itself. Install the newer file over it. A +nightly sorts above the release it follows, so going back to stable is a +downgrade. In a script apt refuses one without `--allow-downgrades`, while dnf +goes ahead. Both carry the man page and completions for bash, zsh and fish. +A release download prints the plain number, `19.0.0`. Any Nix build, and a +nightly download, reads `19.0.0+git202609141144.deb846b`: the last release, the +commit's time in UTC, and the commit. A Homebrew `--HEAD` build reports +`HEAD-deb846b`. A `cargo install` from git sets no version, so it prints +`19.0.0` too. + From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, run `cargo add soulseek-rs-lib`. +To package it for a distribution: a musl build is static, a glibc build links +only libc, libm and libgcc_s, +`soulseek-rs completions print ` and `soulseek-rs man` write the +completion scripts and the man page to stdout, and a build with +`SOULSEEK_RS_VERSION` set reports that instead of the crate version. The nfpm +recipe behind the `.deb` and `.rpm` is in [`packaging/`](packaging). + ## Usage Run it with no subcommand for the interactive TUI: @@ -332,6 +381,8 @@ soulseek-rs config path|list|get|set soulseek-rs portmap # test automatic port mapping soulseek-rs skills install|uninstall|list # teach a coding agent this CLI soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish +soulseek-rs completions print # the script on stdout, for packaging +soulseek-rs man # the man page on stdout ``` A script usually starts with `whoami`, which answers "are these credentials @@ -343,9 +394,9 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Twenty need no credentials: `config path|list|get|set`, +Twenty-two need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall`, +`skills install|uninstall|list`, `completions install|uninstall|print`, `man`, `account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and diff --git a/RELEASE.md b/RELEASE.md index b6356d0..941db0f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,6 +28,11 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | + - packages the two Linux binaries as `soulseek-rs-amd64.deb`, `soulseek-rs-arm64.deb`, + `soulseek-rs-x86_64.rpm` and `soulseek-rs-aarch64.rpm`, with the man page and the + bash, zsh and fish completions. The names carry no version, so + `releases/latest/download/soulseek-rs-amd64.deb` is a link that keeps working, + - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, - merges `master` back into `develop`, so the branch the work happens on carries the @@ -44,9 +49,15 @@ conflict. `.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every day. It resolves the commit once, builds the same six targets as a numbered -release, verifies all twelve archives and checksum files as one set, then -replaces the mutable `nightly` prerelease and tag. The published release is -smoke-tested through both the curl and wget installer paths. +release, packages the two Linux ones as `soulseek-rs-nightly-*.deb` and `.rpm`, +verifies all sixteen files as one set, then replaces the mutable `nightly` +prerelease and tag. Every nightly binary reports `X.Y.Z+git.` +instead of the crate version. The published release is smoke-tested through the +curl and wget installer paths, and by installing the `.deb` and the `.rpm`. + +Never enable GitHub's Immutable Releases on this repository: nightly replaces +its assets and moves its tag, and a tag name burnt by that setting cannot be +reused. Use **Actions → Nightly → Run workflow** to force a build. The workflow always checks out `develop`, regardless of the ref selected in the dispatch form. The diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d9c0837 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c954c07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + in + { + packages = lib.genAttrs systems (system: { + default = nixpkgs.legacyPackages.${system}.callPackage ( + { rustPlatform, installShellFiles }: + rustPlatform.buildRustPackage { + pname = "soulseek-rs"; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildFlags = [ + "--package" + "soulseek-rs" + ]; + doCheck = false; + nativeBuildInputs = [ installShellFiles ]; + env.SOULSEEK_RS_VERSION = "${version}+git${lib.substring 0 12 self.lastModifiedDate}.${self.shortRev or "dirty"}"; + postInstall = '' + installShellCompletion --cmd soulseek-rs \ + --bash <($out/bin/soulseek-rs completions print bash) \ + --zsh <($out/bin/soulseek-rs completions print zsh) \ + --fish <($out/bin/soulseek-rs completions print fish) + $out/bin/soulseek-rs man > soulseek-rs.1 + installManPage soulseek-rs.1 + ''; + meta = { + description = "Soulseek client for the terminal, with a scriptable CLI and daemon"; + homepage = "https://re-invention.nl/soulseek-rs/"; + license = lib.licenses.mit; + mainProgram = "soulseek-rs"; + }; + } + ) { }; + }); + }; +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..1493db5 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,55 @@ +name: soulseek-rs +arch: ${PKG_ARCH} +platform: linux +version: ${PKG_VERSION} +version_schema: none +release: "1" +section: net +priority: optional +maintainer: Michel de Graaf +description: | + Soulseek client for the terminal + A TUI, a scriptable command line and a daemon for the Soulseek + peer-to-peer network. +vendor: Michel de Graaf +homepage: https://re-invention.nl/soulseek-rs/ +license: MIT +suggests: + - gnome-keyring +contents: + - src: target/nfpm/soulseek-rs + dst: /usr/bin/soulseek-rs + file_info: + mode: 0755 + - src: target/nfpm/soulseek-rs.1.gz + dst: /usr/share/man/man1/soulseek-rs.1.gz + - src: target/nfpm/soulseek-rs.bash + dst: /usr/share/bash-completion/completions/soulseek-rs + - src: target/nfpm/soulseek-rs.fish + dst: /usr/share/fish/vendor_completions.d/soulseek-rs.fish + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/vendor-completions/_soulseek-rs + packager: deb + - src: target/nfpm/_soulseek-rs + dst: /usr/share/zsh/site-functions/_soulseek-rs + packager: rpm + - dst: /usr/share/doc/soulseek-rs + type: dir + packager: rpm + - dst: /usr/share/licenses/soulseek-rs + type: dir + packager: rpm + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: deb + - src: README.md + dst: /usr/share/doc/soulseek-rs/README.md + packager: rpm + type: doc + - src: LICENSE + dst: /usr/share/doc/soulseek-rs/copyright + packager: deb + - src: LICENSE + dst: /usr/share/licenses/soulseek-rs/LICENSE + packager: rpm + type: license diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 0000000..8216098 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +umask 022 + +[ $# -ge 4 ] || { + echo "usage: $0 []" >&2 + exit 2 +} + +NFPM_VERSION=2.47.0 + +binary=$1 +PKG_VERSION=$2 +target=$3 +out=$4 +package=${5:-soulseek-rs} + +case "$target" in + x86_64-*) PKG_ARCH=amd64 rpm_arch=x86_64 ;; + aarch64-*) PKG_ARCH=arm64 rpm_arch=aarch64 ;; + *) echo "no package architecture for $target" >&2; exit 1 ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) nfpm_asset=Linux_x86_64 nfpm_sha256=0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783 ;; + Linux-aarch64) nfpm_asset=Linux_arm64 nfpm_sha256=1c0f5f2999b9a974bfb04fdb0cc3306096de530ac5dbb25d739cc5f5219c919c ;; + *) echo "no pinned nfpm build for this machine" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +stage=$root/target/nfpm +rm -rf "$stage" +mkdir -p "$stage" "$out" +out=$(cd "$out" && pwd) + +cp "$binary" "$stage/soulseek-rs" +"$binary" completions print bash > "$stage/soulseek-rs.bash" +"$binary" completions print zsh > "$stage/_soulseek-rs" +"$binary" completions print fish > "$stage/soulseek-rs.fish" +"$binary" man > "$stage/soulseek-rs.1" +gzip -9n "$stage/soulseek-rs.1" + +archive=nfpm_${NFPM_VERSION}_$nfpm_asset.tar.gz +curl -fsSL -o "$work/$archive" \ + "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/$archive" +echo "$nfpm_sha256 $work/$archive" | sha256sum -c - >/dev/null +tar -xzf "$work/$archive" -C "$work" nfpm + +export PKG_ARCH PKG_VERSION +cd "$root" +"$work/nfpm" package --config packaging/nfpm.yaml --packager deb --target "$out/$package-$PKG_ARCH.deb" +"$work/nfpm" package --config packaging/nfpm.yaml --packager rpm --target "$out/$package-$rpm_arch.rpm" diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml index ebc7d14..659c074 100644 --- a/soulseek-rs-lib/Cargo.toml +++ b/soulseek-rs-lib/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs-lib" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 1465749..f40af90 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -3,6 +3,7 @@ name = "soulseek-rs" version.workspace = true authors.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true @@ -29,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap_complete = "4.6.7" +clap_mangen = "0.3.3" ratatui = "0.30.2" unicode-width = "0.2" color-eyre = "0.6.5" diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2dc21fc..387db6d 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`, completion script where bash, zsh, or fish looks for it, or take it back again: `shell`, `path`, `action`. For a human at a prompt, not for you. +**`completions print `** and **`man`** — the completion script or the +man page on stdout, raw rather than records. For whoever builds a package. + ## Idioms ```bash diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 8c5efd0..37b3270 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -34,11 +34,16 @@ Exit codes: 0 success 2 usage/config 3 connect or login 4 no results 5 timed out 6 transfer failed 7 session lost 1 unexpected error"; +pub const VERSION: &str = match option_env!("SOULSEEK_RS_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + #[derive(Parser, Debug, Default)] #[command( name = "soulseek-rs", author, - version, + version = VERSION, about = "Soulseek client in Rust 🦀", long_about = "Soulseek client in Rust.\n\nRun without a subcommand for the \ interactive TUI, or use a subcommand for a scriptable \ @@ -244,6 +249,9 @@ pub enum Commands { /// Tab completion for bash, zsh, and fish #[command(subcommand)] Completions(CompletionsCommand), + + /// Print the man page to stdout, for packaging + Man, } #[derive(Args, Debug, Default)] @@ -310,6 +318,12 @@ pub enum CompletionsCommand { /// Remove the completion script again Uninstall(CompletionsArgs), + + /// Print the completion script to stdout, for packaging + Print { + #[arg(value_enum)] + shell: Shell, + }, } #[derive(Args, Debug)] diff --git a/soulseek-rs/src/commands/completions.rs b/soulseek-rs/src/commands/completions.rs index d6ca9ef..cd5582c 100644 --- a/soulseek-rs/src/commands/completions.rs +++ b/soulseek-rs/src/commands/completions.rs @@ -8,10 +8,10 @@ use super::{unwritable, xdg}; use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; -use crate::output::{CliError, CliResult, CompletionRecord, Out}; +use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out}; use clap::CommandFactory; use directories::UserDirs; -use std::io::Write; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; /// The command being completed, and the file name the scripts are written as. @@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult { match command { CompletionsCommand::Install(args) => install(out, &targets(args)?), CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), + CompletionsCommand::Print { shell } => print(&script(*shell)), + } +} + +pub fn man() -> CliResult { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust")) + .render(&mut page) + .expect("a Vec accepts every write"); + print(&page) +} + +fn print(bytes: &[u8]) -> CliResult { + match std::io::stdout().write_all(bytes) { + Err(error) if error.kind() != ErrorKind::BrokenPipe => { + Err(CliError::new( + Exit::Failure, + format!("cannot write stdout: {error}"), + )) + } + _ => Ok(()), } } diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index c5a009e..65192e9 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { | Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) - | Commands::Completions(_) => { + | Commands::Completions(_) + | Commands::Man => { Err(CliError::usage("handled without a session; see main::run")) } } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index ca7fe3c..201a6d0 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -522,7 +522,7 @@ impl Daemon { fn identify(&self) -> AuthResult { AuthResult { protocol: PROTOCOL_VERSION, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), username: self.session.username(), } } @@ -532,7 +532,7 @@ impl Daemon { DaemonStatus { username: self.session.username(), server: self.server.clone(), - daemon_version: env!("CARGO_PKG_VERSION").to_string(), + daemon_version: crate::cli::VERSION.to_string(), protocol: PROTOCOL_VERSION, listen_port: self.session.listen_port(), shared_folders: folders, diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index eeecf45..0d653b3 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { } } + match &cli.command { + Some(Commands::Completions(command)) => { + return crate::commands::completions::run(out, command); + } + Some(Commands::Man) => return crate::commands::completions::man(), + _ => {} + } + let config_path = config_path(&cli); let file_config = match &config_path { Some(path) => crate::persist::config::FileConfig::load(path) @@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { resolved.username.as_deref(), ); } - Commands::Completions(ref command) => { - return crate::commands::completions::run(out, command); - } Commands::Config(ConfigCommand::Path) => { return crate::commands::settings::config_path(out, &store); } diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index f79f718..24eec71 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,3 +1,4 @@ +use crate::cli::VERSION; use crate::models::DownloadEntry; use crate::ui::{ BYTES_PER_MB, accent_style, dimmed_style, error_style, @@ -14,8 +15,6 @@ use ratatui::{ use soulseek_rs::DownloadStatus; use std::fmt::Write as _; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - /// Bytes transferred, bytes expected and combined speed across the downloads /// that are actually running — or `None` when none are, so the caller can hide /// the progress readout entirely. diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 1b71f59..e111655 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,4 +1,5 @@ use super::name_scroll::{column_width, end_offset, scroll_text}; +use crate::cli::VERSION; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, @@ -13,8 +14,6 @@ use ratatui::{ }; use std::collections::HashSet; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - // The file name and its folder share whatever width the fixed columns leave, // the name getting the larger part: it is what a search is after, and a // folder's tail is a scroll away. diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index efea975..6a44531 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() { assert_eq!(action(&again), "absent"); } +#[test] +fn a_package_build_reads_completions_and_the_man_page_from_stdout() { + let dir = Scratch::new("package-build"); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "not = [toml").expect("a config file"); + let broken = config.display().to_string(); + + for (shell, marker) in [ + ("bash", "complete -F"), + ("zsh", "#compdef soulseek-rs"), + ("fish", "complete -c soulseek-rs"), + ] { + let output = run(&["--config", &broken, "completions", "print", shell]); + assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(marker), "{shell} script"); + } + + let man = run(&["--config", &broken, "man"]); + assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man)); + assert!(stdout(&man).contains(".TH soulseek-rs 1")); +} + // --- wishlist, the half that only touches the config file ------------------ #[test] diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index fdb4f37..54385ed 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -29,3 +29,7 @@ export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` export const NIGHTLY_INSTALL_CMD = 'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly' + +export const STABLE_DOWNLOAD = `${LINKS.releases}/latest/download` + +export const NIGHTLY_DOWNLOAD = `${LINKS.releases}/download/nightly` diff --git a/website/src/pages/install-routes.tsx b/website/src/pages/install-routes.tsx new file mode 100644 index 0000000..2aa1ffc --- /dev/null +++ b/website/src/pages/install-routes.tsx @@ -0,0 +1,218 @@ +import { ExtLink } from '@/components/ui/ext-link' +import { Code } from '@/components/ui/inline-code' +import { OsIcon, type OsName } from '@/components/ui/os-icon' +import { Terminal, type TermLine } from '@/components/ui/terminal' +import { LINKS, NIGHTLY_DOWNLOAD, STABLE_DOWNLOAD } from '@/lib/links' + +interface InstallRoute { + id: string + os: OsName + best: string + lines: readonly TermLine[] + alt: React.ReactNode +} + +// aislop-ignore-file code-quality/duplicate-block -- data tables: the INSTALLS and LINUX_ROUTES records repeat the field names their interfaces require. The rule matches punctuation, not logic. +const INSTALLS: readonly InstallRoute[] = [ + { + id: 'macos', + os: 'macOS', + best: 'Homebrew', + lines: [ + { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a + toolchain, cargo install --locked soulseek-rs builds from crates.io instead. + + ), + }, + { + id: 'linux', + os: 'Linux', + best: 'your package manager', + lines: [ + { t: 'cm', text: '# Debian and Ubuntu; the others are below' }, + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + { t: 'cmd', text: 'soulseek-rs' }, + ], + alt: ( + <> + Fedora, Nix, Homebrew and cargo are{' '} + + listed below + + , each with a nightly. Without any of them, the{' '} + releases page has static musl archives for x86-64 + and arm64 that run on any distribution. + + ), + }, + { + id: 'windows', + os: 'Windows', + best: 'cargo or a prebuilt .exe', + lines: [ + { t: 'cmd', text: 'cargo install --locked soulseek-rs' }, + { t: 'cmd', text: 'soulseek-rs.exe' }, + ], + alt: ( + <> + Cargo needs the MSVC build tools. To skip both, take the{' '} + pc-windows-msvc zip from the{' '} + releases page, unpack it, and put{' '} + soulseek-rs.exe on your PATH. + + ), + }, +] + +interface LinuxRoute { + id: string + name: string + stable: readonly TermLine[] + nightly: readonly TermLine[] + note: React.ReactNode +} + +const LINUX_ROUTES: readonly LinuxRoute[] = [ + { + id: 'deb', + name: 'Debian, Ubuntu, Mint', + stable: [ + { t: 'cmd', text: `curl -fsSLO ${STABLE_DOWNLOAD}/soulseek-rs-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-amd64.deb' }, + ], + nightly: [ + { t: 'cmd', text: `curl -fsSLO ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-amd64.deb` }, + { t: 'cmd', text: 'sudo apt install ./soulseek-rs-nightly-amd64.deb' }, + ], + note: ( + <> + On ARM, arm64 takes the place of amd64. There is no apt + repository behind this, so an update means installing the newer file over the old one. + A nightly sorts above the release it follows, so going back to stable is a downgrade, + and apt asks before doing it. + + ), + }, + { + id: 'rpm', + name: 'Fedora, RHEL', + stable: [{ t: 'cmd', text: `sudo dnf install ${STABLE_DOWNLOAD}/soulseek-rs-x86_64.rpm` }], + nightly: [ + { t: 'cmd', text: `sudo dnf install ${NIGHTLY_DOWNLOAD}/soulseek-rs-nightly-x86_64.rpm` }, + ], + note: ( + <> + On ARM, aarch64 takes the place of x86_64. Run the same + command again to update. dnf fetches the file each time, so a newer nightly at the same + address is picked up. An older file downgrades without asking. + + ), + }, + { + id: 'nix', + name: 'Nix', + stable: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/master' }], + nightly: [{ t: 'cmd', text: 'nix run github:michel/soulseek-rs/develop' }], + note: ( + <> + Both build from source. With no branch at all, Nix takes the repository’s + default one, and here that is develop. + + ), + }, + { + id: 'brew', + name: 'Homebrew', + stable: [{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }], + nightly: [{ t: 'cmd', text: 'brew install --HEAD michel/tap/soulseek-rs' }], + note: ( + <> + Homebrew installs under /home/linuxbrew, outside the image that a system + update replaces on Bazzite and Silverblue. --HEAD compiles{' '} + develop with Homebrew’s own Rust. + + ), + }, + { + id: 'cargo', + name: 'cargo', + stable: [{ t: 'cmd', text: 'cargo install --locked soulseek-rs' }], + nightly: [ + { + t: 'cmd', + text: 'cargo install --locked --git https://github.com/michel/soulseek-rs --branch develop soulseek-rs', + }, + ], + note: ( + <> + Needs Rust 1.91 or newer. Debian 13 and Ubuntu 24.04 default to an older one, so take + the toolchain from rustup.rs.{' '} + cargo binstall soulseek-rs fetches the release binary and compiles nothing. + + ), + }, +] + +export const InstallCards = () => ( +
+ {INSTALLS.map((route) => ( +
+
+ +

+ {route.os} +

+
+ + {route.best} + + +

+ {route.alt} +

+
+ ))} +
+) + +export const LinuxRoutes = () => ( +
+

+ Linux, by distribution +

+

+ Stable is the latest release, or for Nix the master branch it was cut + from. Nightly is the tip of develop, rebuilt every day. A release download + prints the plain number, 19.0.0. Any Nix build, and a nightly download, + reads 19.0.0+git202609141144.deb846b: the last release, the + commit’s time in UTC, and the commit. A Homebrew --HEAD build + reports HEAD-deb846b, and a cargo install from git sets no + version, so it prints 19.0.0 too. The .deb and .rpm + carry the man page and completions for bash, zsh and fish. +

+ {LINUX_ROUTES.map((route) => ( +
+ + {route.name} + +
+ + +
+

+ {route.note} +

+
+ ))} +
+) diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index f17cf70..9e03fb0 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -3,10 +3,10 @@ import { FeatureCard } from '@/components/ui/feature-card' import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout' -import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' +import { InstallCards, LinuxRoutes } from '@/pages/install-routes' const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ { t: 'cm', text: '# latest stable release' }, @@ -36,66 +36,6 @@ const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({ text, })) -interface InstallRoute { - id: string - os: OsName - best: string - lines: readonly TermLine[] - alt: React.ReactNode -} - -// aislop-ignore-file code-quality/duplicate-block -- three findings here, all data tables: the INSTALLS records repeat the field names InstallRoute requires, and the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. -const INSTALLS: readonly InstallRoute[] = [ - { - id: 'macos', - os: 'macOS', - best: 'Homebrew', - lines: [ - { t: 'cmd', text: 'brew install michel/tap/soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - A prebuilt binary for Apple silicon and Intel, no Rust toolchain needed. With a - toolchain, cargo install soulseek-rs builds from crates.io instead. - - ), - }, - { - id: 'linux', - os: 'Linux', - best: 'cargo', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs' }, - ], - alt: ( - <> - Homebrew on Linux works the same as on macOS. Without either, the{' '} - releases page has static musl archives for x86-64 and - arm64 that run on any distribution. - - ), - }, - { - id: 'windows', - os: 'Windows', - best: 'cargo or a prebuilt .exe', - lines: [ - { t: 'cmd', text: 'cargo install soulseek-rs' }, - { t: 'cmd', text: 'soulseek-rs.exe' }, - ], - alt: ( - <> - Cargo needs the MSVC build tools. To skip both, take the{' '} - pc-windows-msvc zip from the{' '} - releases page, unpack it, and put{' '} - soulseek-rs.exe on your PATH. - - ), - }, -] - interface PlatformNote { id: string name: string @@ -291,32 +231,12 @@ const Steps = () => ( place.

- Or pick a route per platform. Cargo needs a Rust toolchain from{' '} - rustup.rs; the other routes don’t. + Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '} + rustup.rs. Nix and Homebrew{' '} + --HEAD also build from source, but bring their own Rust.

-
- {INSTALLS.map((route) => ( -
-
- -

- {route.os} -

-
- - {route.best} - - -

- {route.alt} -

-
- ))} -
+ +

Every route puts the same binary on your PATH. Run soulseek-rs in a terminal and the TUI opens, in a script or a pipe it wants a subcommand @@ -522,11 +442,13 @@ const Config = () => ( ) +// aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. const Uninstall = () => (

Three things exist on disk: the binary, the config, and the state, plus the completion - scripts and the agent skill if you asked for those. Nothing else, unless you point{' '} + scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page + and its own completions, and takes them back when removed. Nothing else, unless you point{' '} --log-file somewhere. No telemetry to opt out of. @@ -539,6 +461,8 @@ const Uninstall = () => ( { t: 'cm', text: '# whichever way you installed it' }, { t: 'cmd', text: 'brew uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' }, + { t: 'cmd', text: 'sudo apt remove soulseek-rs' }, + { t: 'cmd', text: 'sudo dnf remove soulseek-rs' }, { t: 'cm', text: '# script install without brew: the binary itself' }, { t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' }, { t: 'cm', text: '# brew only: drop the tap it came from' }, @@ -581,7 +505,7 @@ const Uninstall = () => ( export const Install = () => ( <> - One command with the install script, Homebrew or cargo. Build from source if + One command with the install script, your package manager, Homebrew or cargo. Build from source if you’d rather. Or depend on the library and write your own client.