diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7b6fa15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +name: Release + +# Pushing a tag such as v0.4.0 builds the `grainx` CLI on native runners, +# packages it and publishes a GitHub release. Running the workflow by hand +# (workflow_dispatch) builds and uploads the archives as workflow artifacts +# but never publishes. See docs/releasing.md. + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + prepare: + name: Check version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Read workspace version and compare with the tag + id: version + run: | + version="$(cargo metadata --no-deps --format-version 1 --locked \ + | jq -r '.packages[] | select(.name == "grainx") | .version')" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "::error::could not read the grainx version from Cargo.toml" + exit 1 + fi + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match the workspace version $version (expected v$version). Fix the version in Cargo.toml or push the right tag." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.target }} + needs: prepare + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + # Linux builds run on the oldest hosted images so the binaries link + # against an older glibc (2.35) and run on most current distributions. + - { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04 } + - { target: aarch64-unknown-linux-gnu, os: ubuntu-22.04-arm } + - { target: x86_64-apple-darwin, os: macos-15-intel } + - { target: aarch64-apple-darwin, os: macos-latest } + - { target: x86_64-pc-windows-msvc, os: windows-latest } + env: + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Build + run: cargo build --release --locked -p grainx --target ${{ matrix.target }} + + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + name="grainx-${VERSION}-${TARGET}" + mkdir -p "dist/${name}" + cp "target/${TARGET}/release/grainx" LICENSE "dist/${name}/" + cp packaging/README.txt "dist/${name}/README.txt" + tar -C dist -czf "dist/${name}.tar.gz" "${name}" + cd dist + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${name}.tar.gz" > "${name}.tar.gz.sha256" + else + shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256" + fi + cat "${name}.tar.gz.sha256" + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $name = "grainx-$env:VERSION-$env:TARGET" + New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null + Copy-Item "target/$env:TARGET/release/grainx.exe" "dist/$name/" + Copy-Item LICENSE "dist/$name/" + Copy-Item packaging/README.txt "dist/$name/README.txt" + Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip" + $hash = (Get-FileHash "dist/$name.zip" -Algorithm SHA256).Hash.ToLower() + "$hash $name.zip" | Set-Content -Path "dist/$name.zip.sha256" -Encoding ascii + Get-Content "dist/$name.zip.sha256" + + - name: Smoke test the binary + shell: bash + run: | + set -euo pipefail + exe="target/${TARGET}/release/grainx" + if [ "$RUNNER_OS" = "Windows" ]; then exe="${exe}.exe"; fi + out="$("$exe" --version)" + echo "$out" + case "$out" in + *"${VERSION}"*) ;; + *) echo "::error::binary reports '$out', expected version ${VERSION}"; exit 1 ;; + esac + + - name: Upload archives + uses: actions/upload-artifact@v4 + with: + name: grainx-${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + dist/*.sha256 + if-no-files-found: error + + release: + name: Publish release + # Only for pushed tags; a manual run stops after the build job. + if: github.ref_type == 'tag' + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: + contents: write + env: + VERSION: ${{ needs.prepare.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: grainx-* + merge-multiple: true + + - name: List files + run: ls -l dist + + - name: Create the GitHub release + run: | + set -euo pipefail + flags="" + case "$VERSION" in *-*) flags="--prerelease" ;; esac + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "grainx $VERSION" \ + --generate-notes \ + $flags \ + dist/*.tar.gz dist/*.zip dist/*.sha256 diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e328f..45392a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ versions, not published artifacts, so the release below is the first one a reade ## [Unreleased] +## [0.1.3] - 2026-10-05 + +- Prebuilt binaries for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon) and Windows on each release, with SHA-256 files, plus `install.sh` and `install.ps1` installers. - Fix dashboard frame clearing, terminal bounds, footer overlap, and scrolling braille graph coordinates with values rising from bottom to top. - Add `--hide-processes` to omit the dashboard process table during screen sharing and recordings. diff --git a/Cargo.lock b/Cargo.lock index 4d386b6..977bae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,7 +487,7 @@ dependencies = [ [[package]] name = "grainx" -version = "0.1.2" +version = "0.1.3" dependencies = [ "axum", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ac0168b..f4b9178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "grainx" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version = "1.88" diff --git a/README.md b/README.md index f114e91..2fcc2bf 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ grainx shows live computer and process activity in a terminal and can share thos [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) > [!NOTE] -> **Status:** Experimental CLI tool (v0.1.2, pre-1.0); CI checks Linux and macOS. +> **Status:** Experimental CLI tool (v0.1.3, pre-1.0); CI checks Linux and macOS. ![The grainx dashboard with the process table hidden](docs/demo/dashboard.gif) @@ -19,6 +19,22 @@ grainx shows live computer and process activity in a terminal and can share thos ## Quick start +### Try the released CLI (macOS or Linux) + +The prebuilt CLI does not require Rust. The installer downloads a release, checks its SHA-256 checksum, and installs to `~/.local/bin`: + +~~~bash +curl -fsSL https://raw.githubusercontent.com/rustfuture/grainx/main/install.sh | sh +export PATH="$HOME/.local/bin:$PATH" +grainx --version +grainx # dashboard; press q to quit +grainx export --json /tmp/grainx-stats.json --csv /tmp/grainx-stats.csv +~~~ + +Prebuilt Linux binaries require glibc; unsupported platforms can build from source. On Windows, use the [PowerShell installer](install.ps1). + +### Build from source + You need Git and stable Rust 1.88 or newer with Cargo ([rustup](https://rustup.rs/)), plus an interactive terminal for the dashboard. The first build downloads Cargo dependencies. The crate uses Rust edition 2024; the minimum version is checked against the committed dependency lockfile. ~~~bash diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..771f9df --- /dev/null +++ b/install.ps1 @@ -0,0 +1,129 @@ +# Installer for the `grainx` CLI (https://github.com/rustfuture/grainx). +# +# irm https://raw.githubusercontent.com/rustfuture/grainx/main/install.ps1 | iex +# +# Downloads the prebuilt Windows release archive, checks its SHA-256 checksum and +# copies grainx.exe into an install directory. It needs no administrator rights. +# It does NOT change your PATH: if the install directory is not on it, the script +# prints the command to add it. +# +# Environment variables (all optional): +# GRAINX_VERSION Version to install, e.g. 0.3.0 or v0.3.0 (default: latest release) +# GRAINX_INSTALL_DIR Where to put grainx.exe (default: $env:LOCALAPPDATA\grainx\bin) +# GRAINX_DOWNLOAD_BASE Base URL, local directory or file:// URL holding the archive +# and its .sha256 file, instead of the GitHub release. Needs +# GRAINX_VERSION. Meant for mirrors and testing. +# GRAINX_REPO GitHub repository (default: rustfuture/grainx) +# +# Everything runs inside a function and reports problems with `throw`, never `exit`, +# so piping this script to `iex` cannot close your terminal. + +function Install-Tool { + Set-StrictMode -Version 2 + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' # the progress bar makes Windows PowerShell 5.1 downloads very slow + + $repo = if ($env:GRAINX_REPO) { $env:GRAINX_REPO } else { 'rustfuture/grainx' } + $fromSource = "cargo install --git https://github.com/$repo grainx" + + # Windows PowerShell 5.1 does not enable TLS 1.2 by default. + try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } + + # --- platform ------------------------------------------------------------- + if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { + throw "This installer is for Windows. On macOS or Linux use: curl -fsSL https://raw.githubusercontent.com/$repo/main/install.sh | sh" + } + $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($osArch) { + 'X64' { } + 'Arm64' { Write-Host 'Windows on ARM: installing the x64 build, which runs under emulation.' } + default { throw "Unsupported CPU architecture: $osArch. No prebuilt binary is available; if you have Rust (1.88 or newer), build from source: $fromSource" } + } + $target = 'x86_64-pc-windows-msvc' + + # --- version -------------------------------------------------------------- + if ($env:GRAINX_VERSION) { + $version = $env:GRAINX_VERSION.TrimStart('v') + } elseif ($env:GRAINX_DOWNLOAD_BASE) { + throw 'GRAINX_DOWNLOAD_BASE is set, so also set GRAINX_VERSION (for example $env:GRAINX_VERSION = ''0.3.0'').' + } else { + Write-Host 'Looking up the latest release...' + try { + $release = Invoke-RestMethod -UseBasicParsing -Uri "https://api.github.com/repos/$repo/releases/latest" -Headers @{ 'User-Agent' = 'grainx-installer' } + } catch { + throw 'Could not look up the latest release (no release published yet, no network, or the GitHub API rate limit). Set $env:GRAINX_VERSION, e.g. $env:GRAINX_VERSION = ''0.3.0''' + } + $version = ([string]$release.tag_name).TrimStart('v') + } + if ($version -notmatch '^[0-9A-Za-z.+-]+$') { throw "Invalid version: $version" } + + $installDir = if ($env:GRAINX_INSTALL_DIR) { $env:GRAINX_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'grainx\bin' } + $name = "grainx-$version-$target" + $archive = "$name.zip" + $base = if ($env:GRAINX_DOWNLOAD_BASE) { $env:GRAINX_DOWNLOAD_BASE } else { "https://github.com/$repo/releases/download/v$version" } + $base = $base.TrimEnd('/', '\') + + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("grainx-install-" + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + Write-Host "Installing grainx $version for $target" + + # --- download --------------------------------------------------------- + foreach ($file in @($archive, "$archive.sha256")) { + $dest = Join-Path $tmp $file + try { + if ($base -match '^https?://') { + Invoke-WebRequest -UseBasicParsing -Uri "$base/$file" -OutFile $dest + } else { + $localBase = if ($base -match '^file://') { ([Uri]$base).LocalPath } else { $base } + Copy-Item -LiteralPath (Join-Path $localBase $file) -Destination $dest + } + } catch { + throw "Could not download $base/$file (is $version a published version for $target?)" + } + } + + # --- verify ----------------------------------------------------------- + $sumLine = (Get-Content -LiteralPath (Join-Path $tmp "$archive.sha256") -TotalCount 1) + $expected = ($sumLine -split '\s+')[0].ToLower() + if ($expected -notmatch '^[0-9a-f]{64}$') { throw "The checksum file $archive.sha256 is not valid." } + $actual = (Get-FileHash -LiteralPath (Join-Path $tmp $archive) -Algorithm SHA256).Hash.ToLower() + if ($expected -ne $actual) { + Write-Error "Checksum mismatch for $archive`n expected: $expected`n actual: $actual" -ErrorAction Continue + throw 'Refusing to install a file that does not match its checksum.' + } + Write-Host 'Checksum OK' + + # --- unpack and install ---------------------------------------------- + Expand-Archive -LiteralPath (Join-Path $tmp $archive) -DestinationPath $tmp -Force + $exe = Join-Path $tmp "$name\grainx.exe" + if (-not (Test-Path -LiteralPath $exe)) { throw "The archive does not contain $name\grainx.exe" } + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $installed = Join-Path $installDir 'grainx.exe' + try { + Copy-Item -LiteralPath $exe -Destination $installed -Force + } catch { + throw "Could not write $installed. If grainx is running (for example as an agent hook), close it and try again; or set `$env:GRAINX_INSTALL_DIR to another directory." + } + + Write-Host "Installed: $installed" + & $installed --version + if ($LASTEXITCODE -ne 0) { throw "The installed binary did not run. Build from source instead: $fromSource" } + + # --- PATH hint (never edited automatically) -------------------------- + $dirs = @($env:PATH -split ';' | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }) + if ($dirs -notcontains $installDir.TrimEnd('\')) { + Write-Host '' + Write-Host "$installDir is not on your PATH. To add it for your user account, run:" + Write-Host " [Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path','User') + ';$installDir', 'User')" + Write-Host 'then open a new terminal. (This installer does not change your PATH.)' + } + Write-Host '' + Write-Host "Next: run 'grainx --help' to get started." + } finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Install-Tool diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9637787 --- /dev/null +++ b/install.sh @@ -0,0 +1,186 @@ +#!/bin/sh +# Installer for the `grainx` CLI (https://github.com/rustfuture/grainx). +# +# curl -fsSL https://raw.githubusercontent.com/rustfuture/grainx/main/install.sh | sh +# +# Downloads the prebuilt release archive for this machine, checks its SHA-256 +# checksum and copies `grainx` into an install directory. It never uses sudo +# and never edits shell startup files. +# +# Environment variables (all optional): +# GRAINX_VERSION Version to install, e.g. 0.3.0 or v0.3.0 (default: latest release) +# GRAINX_INSTALL_DIR Where to put the binary (default: $HOME/.local/bin) +# GRAINX_DOWNLOAD_BASE Base URL (or file:// URL) holding the archive and its +# .sha256 file, instead of the GitHub release. Needs +# GRAINX_VERSION. Meant for mirrors and testing. +# GRAINX_REPO GitHub repository (default: rustfuture/grainx) + +set -eu + +REPO="${GRAINX_REPO:-rustfuture/grainx}" +FROM_SOURCE="cargo install --git https://github.com/${REPO} grainx" + +say() { printf '%s\n' "$*"; } +err() { printf 'grainx-install: error: %s\n' "$*" >&2; } +die() { err "$*"; exit 1; } + +have() { command -v "$1" >/dev/null 2>&1; } + +unsupported() { + err "$1" + err "No prebuilt binary is available for this platform. If you have Rust (1.88 or newer), build from source:" + err " ${FROM_SOURCE}" + exit 1 +} + +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + + case "$arch" in + x86_64 | amd64) arch="x86_64" ;; + aarch64 | arm64) arch="aarch64" ;; + *) unsupported "unsupported CPU architecture: ${arch}" ;; + esac + + case "$os" in + Linux) + # The Linux binaries are linked against glibc; musl (e.g. Alpine) cannot run them. + if have ldd && ldd --version 2>&1 | grep -qi musl; then + unsupported "this Linux uses musl libc, but the prebuilt binaries need glibc" + fi + TARGET="${arch}-unknown-linux-gnu" + ;; + Darwin) + # A shell running under Rosetta reports x86_64 on an Apple Silicon Mac. + if [ "$arch" = "x86_64" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then + arch="aarch64" + fi + TARGET="${arch}-apple-darwin" + ;; + MINGW* | MSYS* | CYGWIN*) + die "this looks like Windows. Use the PowerShell installer instead: irm https://raw.githubusercontent.com/${REPO}/main/install.ps1 | iex" + ;; + *) + unsupported "unsupported operating system: ${os}" + ;; + esac +} + +# fetch URL DEST +fetch() { + if have curl; then + curl -fsSL --retry 3 -o "$2" "$1" || return 1 + elif have wget; then + wget -q -O "$2" "$1" || return 1 + else + die "need curl or wget to download files" + fi +} + +# fetch_stdout URL +fetch_stdout() { + if have curl; then + curl -fsSL --retry 3 "$1" + elif have wget; then + wget -q -O - "$1" + else + die "need curl or wget to download files" + fi +} + +resolve_version() { + if [ -n "${GRAINX_VERSION:-}" ]; then + VERSION="${GRAINX_VERSION#v}" + elif [ -n "${GRAINX_DOWNLOAD_BASE:-}" ]; then + die "GRAINX_DOWNLOAD_BASE is set, so also set GRAINX_VERSION (for example GRAINX_VERSION=0.3.0)" + else + say "Looking up the latest release..." + json="$(fetch_stdout "https://api.github.com/repos/${REPO}/releases/latest")" || + die "could not look up the latest release (no release published yet, no network, or the GitHub API rate limit). Set GRAINX_VERSION, e.g. GRAINX_VERSION=0.3.0" + tag="$(printf '%s\n' "$json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" + [ -n "$tag" ] || die "could not read the latest release tag. Set GRAINX_VERSION, e.g. GRAINX_VERSION=0.3.0" + VERSION="${tag#v}" + fi + case "$VERSION" in + '' | *[!0-9A-Za-z.+-]*) die "invalid version: ${VERSION}" ;; + esac +} + +sha256_of() { + if have sha256sum; then + sha256sum "$1" | awk '{print $1}' + elif have shasum; then + shasum -a 256 "$1" | awk '{print $1}' + else + die "need sha256sum or shasum to verify the download" + fi +} + +main() { + have tar || die "need tar to unpack the download" + have curl || have wget || die "need curl or wget to download files" + + detect_target + resolve_version + + INSTALL_DIR="${GRAINX_INSTALL_DIR:-${HOME:?HOME is not set}/.local/bin}" + NAME="grainx-${VERSION}-${TARGET}" + ARCHIVE="${NAME}.tar.gz" + BASE="${GRAINX_DOWNLOAD_BASE:-https://github.com/${REPO}/releases/download/v${VERSION}}" + BASE="${BASE%/}" + + TMP="$(mktemp -d 2>/dev/null || mktemp -d -t grainx-install)" + trap 'rm -rf "$TMP"' EXIT INT TERM HUP + + say "Installing grainx ${VERSION} for ${TARGET}" + fetch "${BASE}/${ARCHIVE}" "${TMP}/${ARCHIVE}" || + die "could not download ${BASE}/${ARCHIVE} (is ${VERSION} a published version for ${TARGET}?)" + fetch "${BASE}/${ARCHIVE}.sha256" "${TMP}/${ARCHIVE}.sha256" || + die "could not download ${BASE}/${ARCHIVE}.sha256" + + expected="$(awk 'NR == 1 {print tolower($1)}' "${TMP}/${ARCHIVE}.sha256")" + actual="$(sha256_of "${TMP}/${ARCHIVE}")" + case "$expected" in + '' | *[!0-9a-f]*) die "the checksum file ${ARCHIVE}.sha256 is not valid" ;; + esac + if [ "${#expected}" -ne 64 ]; then + die "the checksum file ${ARCHIVE}.sha256 is not valid" + fi + if [ "$expected" != "$actual" ]; then + err "checksum mismatch for ${ARCHIVE}" + err " expected: ${expected}" + err " actual: ${actual}" + die "refusing to install a file that does not match its checksum" + fi + say "Checksum OK" + + tar -xzf "${TMP}/${ARCHIVE}" -C "$TMP" || die "could not unpack ${ARCHIVE}" + [ -f "${TMP}/${NAME}/grainx" ] || die "the archive does not contain ${NAME}/grainx" + + mkdir -p "$INSTALL_DIR" || die "could not create ${INSTALL_DIR}; set GRAINX_INSTALL_DIR to a writable directory" + # Copy under a temporary name and rename, so a running grainx is replaced safely. + cp "${TMP}/${NAME}/grainx" "${INSTALL_DIR}/.grainx.new.$$" || + die "could not write to ${INSTALL_DIR}; set GRAINX_INSTALL_DIR to a writable directory" + chmod 755 "${INSTALL_DIR}/.grainx.new.$$" + mv -f "${INSTALL_DIR}/.grainx.new.$$" "${INSTALL_DIR}/grainx" + + say "Installed: ${INSTALL_DIR}/grainx" + "${INSTALL_DIR}/grainx" --version || + die "the installed binary did not run; it may not suit this system. Build from source instead: ${FROM_SOURCE}" + + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + say "" + say "${INSTALL_DIR} is not on your PATH. Add it, for example:" + say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + say "(put that line in your shell startup file to keep it; this installer does not edit it)" + ;; + esac + + say "" + say "Next: run 'grainx --help' to get started." +} + +main "$@" diff --git a/packaging/README.txt b/packaging/README.txt new file mode 100644 index 0000000..4a2d889 --- /dev/null +++ b/packaging/README.txt @@ -0,0 +1,27 @@ +grainx (grainx) +=============== + +Shows live computer and process activity in a terminal. This archive holds the `grainx` command-line tool, built from the tagged release. + +Install +------- +Put the `grainx` binary (`grainx.exe` on Windows) in a directory on your PATH, +or use the installer, which does that for you and checks the checksum: + + macOS / Linux: curl -fsSL https://raw.githubusercontent.com/rustfuture/grainx/main/install.sh | sh + Windows: irm https://raw.githubusercontent.com/rustfuture/grainx/main/install.ps1 | iex + +Then: + + grainx --version + grainx --help + +Verify this download +-------------------- +Each archive has a matching .sha256 file on the release page: + + sha256sum -c grainx--.tar.gz.sha256 (Linux) + shasum -a 256 -c grainx--.tar.gz.sha256 (macOS) + +Docs and source: https://github.com/rustfuture/grainx +License: see LICENSE