diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cd89ea..2125fd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,4 +48,4 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + RELEASE_GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f504f58..1beba50 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,11 +1,15 @@ version: 2 +# Default optional secrets so templates referencing them resolve even when the +# var is unset (e.g. `make release-snapshot`, where no tap token is exported). +env: + - RELEASE_GITHUB_TOKEN={{ envOrDefault "RELEASE_GITHUB_TOKEN" "" }} + before: hooks: - go mod tidy - # Generate man pages from a fresh local build. Goreleaser runs this once - # before any cross-compiled artifacts are produced, so the pages end up - # in every archive (see archives.files below) regardless of GOOS. + # Built from a local host binary, not a cross-compile — same man pages + # end up in every archive regardless of GOOS. - sh -c 'go run . man -o ./man' builds: @@ -39,14 +43,33 @@ archives: - goos: windows formats: - zip - # Bundle the generated man pages alongside the binary in every archive. - # The Homebrew cask (below) picks them up automatically; users on - # bare-tarball installs can copy `man/*.1` into their MANPATH. + # Bare-tarball users can copy man/*.1 onto their MANPATH. files: - LICENSE - README.md - man/*.1 +nfpms: + - id: emailable + ids: + - emailable + package_name: emailable + vendor: Emailable + homepage: https://emailable.com + maintainer: Emailable + description: Official command-line interface for the Emailable API. + license: MIT + formats: + - deb + - rpm + - apk + bindir: /usr/bin + contents: + # nfpm expands globs, so this picks up every generated page without a + # per-command list to keep in sync (unlike the Homebrew cask below). + - src: ./man/*.1 + dst: /usr/share/man/man1/ + checksum: name_template: "checksums.txt" @@ -74,13 +97,12 @@ homebrew_casks: repository: owner: emailable name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + token: "{{ .Env.RELEASE_GITHUB_TOKEN }}" homepage: "https://emailable.com" description: "Official command-line interface for the Emailable API" skip_upload: auto - # Install the bundled man(1) pages so `man emailable` works out of the - # box for cask users. Globs aren't supported — list each page explicitly. - # Regenerate this list whenever a top-level command is added or removed. + # Globs aren't supported — list each page explicitly. Regenerate this + # list when a top-level command is added or removed. manpages: - man/emailable.1 - man/emailable-account.1 @@ -97,3 +119,20 @@ homebrew_casks: - man/emailable-status.1 - man/emailable-verify.1 - man/emailable-version.1 + +scoops: + - name: emailable + ids: + - emailable + repository: + owner: emailable + name: scoop-bucket + # Same PAT as the Homebrew cask above; needs `contents:write` on both + # the homebrew-tap and scoop-bucket repos. + token: "{{ .Env.RELEASE_GITHUB_TOKEN }}" + homepage: https://emailable.com + description: Official command-line interface for the Emailable API. + license: MIT + # Skip the upload when the token is missing so `make release-snapshot` + # and the first release (before the bucket repo exists) keep working. + skip_upload: '{{ if eq .Env.RELEASE_GITHUB_TOKEN "" }}true{{ else }}auto{{ end }}' diff --git a/README.md b/README.md index 02de3fc..9a495be 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,72 @@ See the [CLI docs](https://emailable.com/docs/api/?code_language=cli). ## Installation -### Homebrew +### Quick install + +**macOS, Linux, WSL2:** + +```bash +curl -fsSL https://emailable.com/install-cli | bash +``` + +**Windows (PowerShell):** + +```powershell +irm https://emailable.com/install-cli.ps1 | iex +``` + +Both scripts pick the right archive for your OS/arch, verify it against the +published `checksums.txt`, and install the binary (plus bundled man pages on +Unix). Override the version with `EMAILABLE_VERSION=v0.2.0` or the install +prefix with `EMAILABLE_PREFIX=$HOME/.local`. + +### Package managers + +**Homebrew (macOS):** ```bash brew install emailable/tap/emailable ``` -### Prebuilt binaries +**Scoop (Windows):** -Download the archive for your OS and architecture from the -[releases page](https://github.com/emailable/emailable-cli/releases), extract -it, and put the binary somewhere on your `PATH`. +```powershell +scoop bucket add emailable https://github.com/emailable/scoop-bucket +scoop install emailable +``` + +In each snippet below, set `ver`/`arch` to the release you want (use +`arch=arm64` on ARM). The `checksums.txt` step verifies the download before +installing — these are GitHub-hosted artifacts, not served from a signed repo. + +**Debian / Ubuntu:** ```bash -tar -xzf emailable___.tar.gz -mv emailable /usr/local/bin/ +ver= arch=amd64 +base="https://github.com/emailable/emailable-cli/releases/download/v$ver" +curl -fsSLO "$base/emailable_${ver}_linux_$arch.deb" +curl -fsSL "$base/checksums.txt" | sha256sum -c --ignore-missing +sudo apt install "./emailable_${ver}_linux_$arch.deb" +``` + +**Fedora / RHEL:** + +```bash +ver= arch=amd64 +base="https://github.com/emailable/emailable-cli/releases/download/v$ver" +curl -fsSLO "$base/emailable_${ver}_linux_$arch.rpm" +curl -fsSL "$base/checksums.txt" | sha256sum -c --ignore-missing +sudo dnf install "./emailable_${ver}_linux_$arch.rpm" +``` + +**Alpine:** + +```bash +ver= arch=amd64 +base="https://github.com/emailable/emailable-cli/releases/download/v$ver" +curl -fsSLO "$base/emailable_${ver}_linux_$arch.apk" +curl -fsSL "$base/checksums.txt" | sha256sum -c --ignore-missing +sudo apk add --allow-untrusted "./emailable_${ver}_linux_$arch.apk" ``` ### From source @@ -34,6 +85,18 @@ mv emailable /usr/local/bin/ go install github.com/emailable/emailable-cli@latest ``` +### Prebuilt binaries + +Download the archive for your OS and architecture from the +[releases page](https://github.com/emailable/emailable-cli/releases), verify it +against `checksums.txt`, extract, and drop the binary on your `PATH`: + +```bash +tar -xzf emailable___.tar.gz +sha256sum -c checksums.txt --ignore-missing +mv emailable /usr/local/bin/ +``` + ## Usage ### Authentication diff --git a/cmd/root.go b/cmd/root.go index 0693524..76965c0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,9 +59,14 @@ const ( // versionDisplay returns the multi-line version blurb used by both `--version` // and the `version` subcommand. func versionDisplay() string { + // Use the resolved version (collectVersionInfo falls back to the Go + // toolchain's module version for `go install` builds where ldflags + // weren't injected). + v := collectVersionInfo().Version + var b strings.Builder b.WriteString("emailable version ") - b.WriteString(version) + b.WriteString(v) if extras := versionExtras(); extras != "" { b.WriteString(" (") @@ -75,8 +80,8 @@ func versionDisplay() string { b.WriteString("]") } - if version != "" && version != "dev" { - tag := version + if v != "" && v != "dev" { + tag := v if !strings.HasPrefix(tag, "v") { tag = "v" + tag } @@ -106,9 +111,11 @@ func collectVersionInfo() versionInfo { if !ok { return vi } + fromVCS := false for _, s := range info.Settings { switch s.Key { case "vcs.revision": + fromVCS = true if len(s.Value) > 7 { vi.Commit = s.Value[:7] } else { @@ -124,6 +131,15 @@ func collectVersionInfo() versionInfo { vi.Dirty = s.Value == "true" } } + // `go install module@vX.Y.Z` injects no ldflags and builds from the module + // cache (no vcs.* settings), so the package-level `version` is still "dev". + // Fall back to the toolchain-recorded module version there. A local checkout + // always carries VCS info — its Main.Version is an untagged pseudo-version, + // not a real release, so leave it as "dev" rather than print a 404 tag URL. + if !fromVCS && (vi.Version == "" || vi.Version == "dev") && + info.Main.Version != "" && info.Main.Version != "(devel)" { + vi.Version = strings.TrimPrefix(info.Main.Version, "v") + } return vi } diff --git a/go.mod b/go.mod index 4b5ebdc..555f431 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.26.3 require ( github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 @@ -15,6 +14,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..3a8c803 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,104 @@ +# Install the Emailable CLI on Windows. +# +# Usage (PowerShell): +# irm https://emailable.com/install-cli.ps1 | iex +# +# Environment overrides: +# $env:EMAILABLE_VERSION Specific version to install (e.g. v0.2.0). +# Defaults to the latest GitHub release. +# $env:EMAILABLE_PREFIX Install prefix. Defaults to +# "$env:LOCALAPPDATA\Programs\emailable". The +# binary lands directly under this directory. + +$ErrorActionPreference = 'Stop' + +$Repo = 'emailable/emailable-cli' +$Binary = 'emailable' + +function Abort([string]$msg) { + Write-Host "Error: $msg" -ForegroundColor Red + exit 1 +} + +# --- detect architecture -------------------------------------------------- + +# A 32-bit PowerShell on 64-bit Windows reports x86 in PROCESSOR_ARCHITECTURE; +# PROCESSOR_ARCHITEW6432 holds the true machine arch in that case. +$procArch = $env:PROCESSOR_ARCHITEW6432 +if (-not $procArch) { $procArch = $env:PROCESSOR_ARCHITECTURE } + +$arch = switch ($procArch) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { Abort "unsupported architecture: $procArch" } +} + +# --- resolve version ------------------------------------------------------ + +$version = $env:EMAILABLE_VERSION +if (-not $version) { + try { + $resp = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 ` + -Uri "https://github.com/$Repo/releases/latest" -ErrorAction SilentlyContinue + } catch { + $resp = $_.Exception.Response + } + $location = $null + if ($resp -and $resp.Headers) { $location = $resp.Headers['Location'] } + if (-not $location) { Abort "could not determine latest version" } + $version = ($location -split '/tag/')[-1] +} +$version = $version.TrimStart('v') +$tag = "v$version" + +# --- pick prefix ---------------------------------------------------------- + +$prefix = $env:EMAILABLE_PREFIX +if (-not $prefix) { + $prefix = Join-Path $env:LOCALAPPDATA 'Programs\emailable' +} +New-Item -ItemType Directory -Force -Path $prefix | Out-Null + +# --- download & verify ---------------------------------------------------- + +$archive = "${Binary}_${version}_windows_${arch}.zip" +$baseUrl = "https://github.com/$Repo/releases/download/$tag" + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + Write-Host "Downloading $archive from $tag..." + Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/$archive" -OutFile (Join-Path $tmp $archive) + Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/checksums.txt" -OutFile (Join-Path $tmp 'checksums.txt') + + Write-Host "Verifying checksum..." + $expected = (Get-Content (Join-Path $tmp 'checksums.txt') ` + | Where-Object { $_ -match " $([regex]::Escape($archive))$" } ` + | ForEach-Object { ($_ -split '\s+')[0] } ` + | Select-Object -First 1) + if (-not $expected) { Abort "no checksum entry for $archive" } + + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $tmp $archive)).Hash.ToLower() + if ($expected.ToLower() -ne $actual) { + Abort "checksum mismatch (expected $expected, got $actual)" + } + + Write-Host "Installing to $prefix\$Binary.exe..." + Expand-Archive -Force -LiteralPath (Join-Path $tmp $archive) -DestinationPath $tmp + Copy-Item -Force -Path (Join-Path $tmp "$Binary.exe") -Destination (Join-Path $prefix "$Binary.exe") +} finally { + Remove-Item -Recurse -Force -Path $tmp -ErrorAction SilentlyContinue +} + +# --- ensure prefix is on the user PATH ------------------------------------ + +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if (-not $userPath) { $userPath = '' } +$pathParts = $userPath -split ';' | Where-Object { $_ -ne '' } +if (-not ($pathParts -contains $prefix)) { + $newPath = if ($userPath) { "$userPath;$prefix" } else { $prefix } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Host "Added $prefix to your user PATH. Open a new terminal to pick up the change." -ForegroundColor Yellow +} + +Write-Host "Installed $Binary $version to $prefix\$Binary.exe" -ForegroundColor Green diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..10a944c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# Install the Emailable CLI. +# +# Usage: +# curl -fsSL https://emailable.com/install-cli | bash +# +# Environment overrides: +# EMAILABLE_VERSION Specific version to install (e.g. v0.2.0). Defaults to +# the latest GitHub release. +# EMAILABLE_PREFIX Install prefix. Defaults to /usr/local when writable, +# otherwise $HOME/.local. Binary goes into /bin +# and man pages into /share/man/man1. +# EMAILABLE_NO_MAN Set to any non-empty value to skip man-page install. +# +# The script picks the right release tarball for your OS/arch, verifies it +# against the published checksums.txt, and installs the binary + bundled man +# pages. It uses sudo only when necessary (system-wide prefix on a tree the +# current user can't write to). + +set -euo pipefail + +REPO="emailable/emailable-cli" +BINARY="emailable" + +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +dim() { printf '\033[2m%s\033[0m\n' "$*"; } + +abort() { red "Error: $*"; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || abort "missing required command: $1" +} + +need curl +need tar +need uname +need mktemp +need install + +# --- detect OS / arch ------------------------------------------------------- + +os_raw=$(uname -s) +arch_raw=$(uname -m) + +case "$os_raw" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) abort "unsupported OS: $os_raw (use the Windows PowerShell installer instead)" ;; +esac + +case "$arch_raw" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) abort "unsupported architecture: $arch_raw" ;; +esac + +# --- resolve version -------------------------------------------------------- + +version="${EMAILABLE_VERSION:-}" +if [ -z "$version" ]; then + # Use the redirect target of /releases/latest rather than the API to avoid + # unauthenticated rate limits. + version=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/${REPO}/releases/latest" | sed 's#.*/tag/##') + [ -n "$version" ] || abort "could not determine latest version" +fi +version="${version#v}" +tag="v${version}" + +# --- pick prefix ------------------------------------------------------------ + +prefix="${EMAILABLE_PREFIX:-}" +if [ -z "$prefix" ]; then + if [ -w /usr/local ] || [ "$(id -u)" -eq 0 ]; then + prefix=/usr/local + elif [ -d /usr/local ] && command -v sudo >/dev/null 2>&1; then + prefix=/usr/local + else + prefix="$HOME/.local" + fi +fi + +bindir="$prefix/bin" +mandir="$prefix/share/man/man1" + +# --- download & verify ------------------------------------------------------ + +archive="${BINARY}_${version}_${os}_${arch}.tar.gz" +base_url="https://github.com/${REPO}/releases/download/${tag}" + +# Bare `mktemp -d` works on GNU and modern BSD/macOS; the templated form is a +# fallback for stricter mktemp implementations that demand one. +tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t emailable) +trap 'rm -rf "$tmpdir"' EXIT + +dim "Downloading $archive from $tag..." +curl -fsSL -o "$tmpdir/$archive" "$base_url/$archive" +curl -fsSL -o "$tmpdir/checksums.txt" "$base_url/checksums.txt" + +dim "Verifying checksum..." +# Field match on the exact filename — avoids the dots in $archive being +# treated as regex wildcards against unrelated checksum lines. +expected=$(awk -v f="$archive" '$2 == f {print $1}' "$tmpdir/checksums.txt") +[ -n "$expected" ] || abort "no checksum entry for $archive" + +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmpdir/$archive" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmpdir/$archive" | awk '{print $1}') +else + abort "neither sha256sum nor shasum is available" +fi + +[ "$expected" = "$actual" ] || abort "checksum mismatch (expected $expected, got $actual)" + +# --- extract & install ------------------------------------------------------ + +tar -xzf "$tmpdir/$archive" -C "$tmpdir" + +# sudo wrapper: only escalate when we can't write the target directory. +maybe_sudo() { + if [ -w "$(dirname "$1")" ] || [ "$(id -u)" -eq 0 ]; then + "${@:2}" + else + sudo "${@:2}" + fi +} + +dim "Installing to $bindir/$BINARY..." +maybe_sudo "$bindir" install -d -m 0755 "$bindir" +maybe_sudo "$bindir/$BINARY" install -m 0755 "$tmpdir/$BINARY" "$bindir/$BINARY" + +if [ -z "${EMAILABLE_NO_MAN:-}" ] && [ -d "$tmpdir/man" ]; then + dim "Installing man pages to $mandir..." + maybe_sudo "$mandir" install -d -m 0755 "$mandir" + for page in "$tmpdir"/man/*.1; do + [ -e "$page" ] || continue + maybe_sudo "$mandir/$(basename "$page")" install -m 0644 "$page" "$mandir/" + done +fi + +green "Installed $BINARY $version to $bindir/$BINARY" + +# Warn if the install prefix isn't on PATH (common for ~/.local). +case ":$PATH:" in + *":$bindir:"*) ;; + *) dim "Note: $bindir is not on your PATH. Add it to your shell profile:" + dim " export PATH=\"$bindir:\$PATH\"" + ;; +esac