Skip to content

Commit 265e6e7

Browse files
feat(install): first-party curl installer for Linux/macOS (#3)
* feat(install): add curl installer script for Linux and macOS Add scripts/install.sh (parity with git-fire): resolve latest or VERSION tag, download tar.gz + checksums.txt from GitHub Releases, verify SHA256, install to INSTALL_DIR (default ~/.local/bin). Document in README and GoReleaser release footers. Windows remains WinGet or manual .zip. Made-with: Cursor * fix(install): tag-pinned release curl, README env on bash, INSTALL_DIR guard - GoReleaser footers: fetch install.sh at {{ .Tag }} and pass VERSION={{ .Tag }} to bash for reproducible release notes. - README: move VERSION/INSTALL_DIR to the bash side of the pipeline. - install.sh: fail fast if INSTALL_DIR exists but is not a directory. Addresses CodeRabbit review on PR #3. Made-with: Cursor * feedback * fix(install): harden installer and CI Resolve VERSION against real release tags (try v-prefix and bare), optional GITHUB_TOKEN/GH_TOKEN for the releases/latest API, defensive checksum parsing, install/tar preflight, PATH dir normalization, and README notes. Add a shellcheck job for scripts/install.sh. Made-with: Cursor * fix(install): send GitHub API version header on wget path Match authenticated curl behavior so wget uses the same REST API version. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 5ea771c commit 265e6e7

5 files changed

Lines changed: 339 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ permissions:
1010
contents: read
1111

1212
jobs:
13+
shellcheck:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: ShellCheck install.sh
18+
run: |
19+
sudo apt-get update -qq
20+
sudo apt-get install -y shellcheck
21+
shellcheck scripts/install.sh
22+
1323
lint:
1424
runs-on: ubuntu-latest
1525
steps:
@@ -44,7 +54,7 @@ jobs:
4454

4555
uat:
4656
runs-on: ubuntu-latest
47-
needs: [lint, test]
57+
needs: [shellcheck, lint, test]
4858
steps:
4959
- uses: actions/checkout@v4
5060

.goreleaser.stable.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ release:
132132
go install github.com/git-rain/git-rain@{{ .Tag }}
133133
```
134134
135+
**curl (Linux / macOS)**
136+
```sh
137+
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/{{ .Tag }}/scripts/install.sh | VERSION={{ .Tag }} bash
138+
```
139+
135140
**Homebrew (macOS/Linuxbrew)**
136141
```sh
137142
brew install git-fire/tap/git-rain

.goreleaser.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ release:
9696
go install github.com/git-rain/git-rain@{{ .Tag }}
9797
```
9898
99+
**curl (Linux / macOS)**
100+
```sh
101+
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/{{ .Tag }}/scripts/install.sh | VERSION={{ .Tag }} bash
102+
```
103+
99104
**Binaries**
100105
Download platform archives from this release's assets.
101106
Package-manager channels are published for stable tags (`vX.Y.Z`).

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on
4646
- [Install](#install)
4747
- [Homebrew (macOS/Linuxbrew)](#homebrew-macoslinuxbrew)
4848
- [WinGet (Windows)](#winget-windows)
49+
- [curl installer (Linux / macOS)](#curl-installer-linux--macos)
4950
- [Linux native packages (.deb / .rpm)](#linux-native-packages-deb--rpm)
5051
- [Go install](#go-install)
5152
- [Binary archive (manual)](#binary-archive-manual)
@@ -90,6 +91,7 @@ git-rain --rain
9091
|---|---|---|
9192
| Homebrew | `brew install git-fire/tap/git-rain` | macOS, Linuxbrew |
9293
| WinGet | `winget install git-rain.git-rain` | Windows |
94+
| curl script | [curl installer](#curl-installer-linux--macos) | Linux, macOS |
9395
| Linux package | Download `.deb` or `.rpm` from [GitHub Releases](https://github.com/git-fire/git-rain/releases) | Linux |
9496
| Go | `go install github.com/git-rain/git-rain@latest` | All (Go 1.24.2+) |
9597
| Binary archive | [GitHub Releases](https://github.com/git-fire/git-rain/releases) | All |
@@ -107,6 +109,35 @@ brew install git-rain
107109
winget install git-rain.git-rain
108110
```
109111

112+
### curl installer (Linux / macOS)
113+
114+
First-party install script (same idea as [`git-fire/scripts/install.sh`](https://github.com/git-fire/git-fire/blob/main/scripts/install.sh)): downloads the matching `.tar.gz` from [Releases](https://github.com/git-fire/git-rain/releases), verifies `checksums.txt`, and installs to `$INSTALL_DIR` (default `~/.local/bin`).
115+
116+
The `main` URL below always runs the installer script from the latest commit on that branch, while the binary itself comes from the latest GitHub release (or from `VERSION` if you set it). That is convenient for copy-paste installs, but it means the script can drift ahead of any given release. For a fully pinned install, use the release tag in the URL (as in each release’s notes) and set `VERSION` to the same tag.
117+
118+
For repeated automation against the GitHub API (resolving `latest`), set **`GITHUB_TOKEN`** or **`GH_TOKEN`** so authenticated rate limits apply. `VERSION` may be a bare semver (`0.9.1`); the installer tries the `v`-prefixed release tag first, then the exact string you passed.
119+
120+
```bash
121+
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
122+
```
123+
124+
Pin a version or install directory (environment variables must apply to `bash`, not `curl`):
125+
126+
```bash
127+
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | VERSION=v0.9.1 INSTALL_DIR=/usr/local/bin bash
128+
```
129+
130+
If your shell does not already include `~/.local/bin` on `PATH`, add it (the installer prints a reminder). Example for bash (skips the line if `.local/bin` is already mentioned in `~/.bashrc`):
131+
132+
```bash
133+
if ! grep -qF '.local/bin' ~/.bashrc 2>/dev/null; then
134+
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
135+
fi
136+
source ~/.bashrc
137+
```
138+
139+
Windows is not supported by this script — use **WinGet** or download a `.zip` from Releases.
140+
110141
### Linux native packages (`.deb` / `.rpm`)
111142

112143
Download from [GitHub Releases](https://github.com/git-fire/git-rain/releases), then:

scripts/install.sh

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Install git-rain from GitHub release assets with checksum verification.
5+
# Usage:
6+
# curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
7+
# Optional env vars:
8+
# VERSION=v0.9.1 (must match a GitHub release tag; bare semver like 0.9.1 tries v0.9.1 first)
9+
# INSTALL_DIR=$HOME/.local/bin
10+
# REPO=git-fire/git-rain
11+
# GITHUB_TOKEN or GH_TOKEN (optional; increases api.github.com rate limits for "latest" resolution)
12+
13+
REPO="${REPO:-git-fire/git-rain}"
14+
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
15+
VERSION="${VERSION:-}"
16+
BINARY_NAME="git-rain"
17+
18+
log() {
19+
printf '[git-rain install] %s\n' "$1"
20+
}
21+
22+
fail() {
23+
printf '[git-rain install] ERROR: %s\n' "$1" >&2
24+
exit 1
25+
}
26+
27+
need_cmd() {
28+
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
29+
}
30+
31+
github_token() {
32+
printf '%s' "${GITHUB_TOKEN:-${GH_TOKEN:-}}"
33+
}
34+
35+
download_to() {
36+
local src="$1"
37+
local dst="$2"
38+
if command -v curl >/dev/null 2>&1; then
39+
curl -fsSL "$src" -o "$dst"
40+
elif command -v wget >/dev/null 2>&1; then
41+
wget -qO "$dst" "$src"
42+
else
43+
fail "curl or wget is required"
44+
fi
45+
}
46+
47+
fetch_github_api() {
48+
local url="$1"
49+
if command -v curl >/dev/null 2>&1; then
50+
local token
51+
token="$(github_token)"
52+
if [ -n "$token" ]; then
53+
curl -fsSL \
54+
-H "Authorization: Bearer ${token}" \
55+
-H "Accept: application/vnd.github+json" \
56+
-H "X-GitHub-Api-Version: 2022-11-28" \
57+
"$url"
58+
else
59+
curl -fsSL "$url"
60+
fi
61+
elif command -v wget >/dev/null 2>&1; then
62+
local token
63+
token="$(github_token)"
64+
if [ -n "$token" ]; then
65+
wget -qO- \
66+
--header="Authorization: Bearer ${token}" \
67+
--header="Accept: application/vnd.github+json" \
68+
--header="X-GitHub-Api-Version: 2022-11-28" \
69+
"$url"
70+
else
71+
wget -qO- "$url"
72+
fi
73+
else
74+
fail "curl or wget is required"
75+
fi
76+
}
77+
78+
sha256_file() {
79+
local target="$1"
80+
if command -v sha256sum >/dev/null 2>&1; then
81+
sha256sum "$target" | awk '{print $1}'
82+
elif command -v shasum >/dev/null 2>&1; then
83+
shasum -a 256 "$target" | awk '{print $1}'
84+
else
85+
fail "sha256sum or shasum is required"
86+
fi
87+
}
88+
89+
# First field is digest; remainder is filename (GNU text " " or binary " *").
90+
checksum_for_file() {
91+
local sums="$1"
92+
local want="$2"
93+
awk -v want="$want" '
94+
{
95+
hash = $1
96+
name = $0
97+
sub(/^[^[:space:]]+[[:space:]]+/, "", name)
98+
sub(/^\*/, "", name)
99+
if (name == want) {
100+
print hash
101+
exit
102+
}
103+
}' "$sums"
104+
}
105+
106+
normalize_os() {
107+
local raw_os
108+
raw_os="$(uname -s | tr '[:upper:]' '[:lower:]')"
109+
case "$raw_os" in
110+
linux) echo "linux" ;;
111+
darwin) echo "darwin" ;;
112+
*)
113+
fail "unsupported OS: $raw_os (expected linux or darwin). On Windows use WinGet or a release .zip from GitHub."
114+
;;
115+
esac
116+
}
117+
118+
normalize_arch() {
119+
local raw_arch
120+
raw_arch="$(uname -m)"
121+
case "$raw_arch" in
122+
x86_64 | amd64) echo "amd64" ;;
123+
aarch64 | arm64) echo "arm64" ;;
124+
# linux/arm is published as armv6 only; armv7l is ABI-compatible.
125+
armv6l | armv7l) echo "armv6" ;;
126+
i386 | i686) echo "386" ;;
127+
*)
128+
fail "unsupported architecture: $raw_arch"
129+
;;
130+
esac
131+
}
132+
133+
resolve_raw_version_tag() {
134+
if [ -n "$VERSION" ]; then
135+
printf '%s\n' "$VERSION"
136+
return
137+
fi
138+
139+
local api_url response tag
140+
api_url="https://api.github.com/repos/$REPO/releases/latest"
141+
response="$(fetch_github_api "$api_url")"
142+
tag="$(printf '%s\n' "$response" | awk -F '"' '/"tag_name"[[:space:]]*:/ {print $4; exit}')"
143+
[ -n "$tag" ] || fail "could not resolve latest release tag from GitHub API"
144+
printf '%s\n' "$tag"
145+
}
146+
147+
release_archive_url() {
148+
local tag="$1"
149+
local archive_name="$2"
150+
printf 'https://github.com/%s/releases/download/%s/%s\n' "$REPO" "$tag" "$archive_name"
151+
}
152+
153+
# Return 0 if a release asset URL responds with success (follows redirects).
154+
release_asset_head_ok() {
155+
local url="$1"
156+
local code
157+
if command -v curl >/dev/null 2>&1; then
158+
code="$(curl -gfsSIL -L -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || printf '%s' "000")"
159+
[ "$code" = "200" ]
160+
elif command -v wget >/dev/null 2>&1; then
161+
wget --spider -q -L "$url"
162+
else
163+
false
164+
fi
165+
}
166+
167+
# Map user input or "latest" API tag to the GitHub release tag that owns the archive.
168+
pick_release_tag() {
169+
local raw="$1"
170+
local -a candidates=()
171+
case "$raw" in
172+
v*)
173+
candidates=("$raw" "${raw#v}")
174+
;;
175+
*)
176+
candidates=("v${raw}" "${raw}")
177+
;;
178+
esac
179+
180+
local tag archive_version archive_name url
181+
for tag in "${candidates[@]}"; do
182+
archive_version="${tag#v}"
183+
archive_name="${BINARY_NAME}_${archive_version}_${os}_${arch}.tar.gz"
184+
url="$(release_archive_url "$tag" "$archive_name")"
185+
if release_asset_head_ok "$url"; then
186+
printf '%s\n' "$tag"
187+
return
188+
fi
189+
done
190+
191+
fail "no GitHub release matched VERSION=${raw} for ${os}/${arch} (check tag spelling and that this platform is published)"
192+
}
193+
194+
normalize_path_dir() {
195+
local d="$1"
196+
while [ "${#d}" -gt 1 ] && [ "${d%/}" != "$d" ]; do
197+
d="${d%/}"
198+
done
199+
printf '%s\n' "$d"
200+
}
201+
202+
install_binary() {
203+
local src_bin="$1"
204+
local target_dir="$2"
205+
local target_bin="$target_dir/$BINARY_NAME"
206+
207+
if [ -e "$target_dir" ] && [ ! -d "$target_dir" ]; then
208+
fail "install path exists but is not a directory: $target_dir"
209+
fi
210+
211+
if [ ! -d "$target_dir" ]; then
212+
if mkdir -p "$target_dir" 2>/dev/null; then
213+
:
214+
elif command -v sudo >/dev/null 2>&1; then
215+
sudo mkdir -p "$target_dir"
216+
else
217+
fail "could not create install directory: $target_dir"
218+
fi
219+
fi
220+
221+
if [ -w "$target_dir" ]; then
222+
install -m 0755 "$src_bin" "$target_bin"
223+
return
224+
fi
225+
226+
if command -v sudo >/dev/null 2>&1; then
227+
sudo install -m 0755 "$src_bin" "$target_bin"
228+
return
229+
fi
230+
231+
fail "install directory is not writable and sudo is unavailable: $target_dir"
232+
}
233+
234+
path_has_dir() {
235+
local dir
236+
dir="$(normalize_path_dir "$1")"
237+
case ":${PATH:-}:" in
238+
*":${dir}:"*) return 0 ;;
239+
*) return 1 ;;
240+
esac
241+
}
242+
243+
# Preflight: fail fast before downloads (curl/wget checked in download_to).
244+
need_cmd tar
245+
need_cmd install
246+
os="$(normalize_os)"
247+
arch="$(normalize_arch)"
248+
version_tag="$(pick_release_tag "$(resolve_raw_version_tag)")"
249+
version="${version_tag#v}"
250+
251+
archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
252+
archive_url="$(release_archive_url "$version_tag" "$archive_name")"
253+
checksums_url="$(release_archive_url "$version_tag" "checksums.txt")"
254+
255+
log "installing ${BINARY_NAME} ${version_tag} for ${os}/${arch}"
256+
257+
tmp_dir="$(mktemp -d)"
258+
trap 'rm -rf "$tmp_dir"' EXIT
259+
260+
archive_path="$tmp_dir/$archive_name"
261+
checksums_path="$tmp_dir/checksums.txt"
262+
263+
log "downloading release archive"
264+
download_to "$archive_url" "$archive_path"
265+
266+
log "downloading checksum file"
267+
download_to "$checksums_url" "$checksums_path"
268+
269+
expected_sum="$(checksum_for_file "$checksums_path" "$archive_name")"
270+
[ -n "$expected_sum" ] || fail "could not find checksum entry for $archive_name (no asset for this OS/arch on this release?)"
271+
272+
actual_sum="$(sha256_file "$archive_path")"
273+
if [ "$expected_sum" != "$actual_sum" ]; then
274+
fail "checksum mismatch for $archive_name"
275+
fi
276+
277+
log "checksum verified"
278+
tar -xzf "$archive_path" -C "$tmp_dir"
279+
[ -f "$tmp_dir/$BINARY_NAME" ] || fail "archive did not contain $BINARY_NAME"
280+
281+
install_binary "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR"
282+
283+
log "installed to $INSTALL_DIR/$BINARY_NAME"
284+
if ! path_has_dir "$INSTALL_DIR"; then
285+
log "warning: $INSTALL_DIR is not on PATH; add it to your shell profile, e.g. export PATH=\"$INSTALL_DIR:\$PATH\""
286+
fi
287+
log "verify with: $BINARY_NAME --version"

0 commit comments

Comments
 (0)