diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ebc40ef..8c02515 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,18 @@ jobs:
with:
version: v2.14.0
install-mode: goinstall
+ install:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: sudo apt-get update && sudo apt-get install -y shellcheck dash
+ - run: shellcheck -s sh install.sh scripts/test-install
+ - run: scripts/test-install dash sh bash
+ # Alpine has no curl and a BusyBox wget (no --max-redirect); the whole suite
+ # runs there so the wget fallback is exercised for real.
+ - run: >
+ docker run --rm --network host -v "$PWD:/src:ro" alpine:3.23
+ sh -c 'apk add --no-cache python3 >/dev/null && sh /src/scripts/test-install sh'
integration:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index dc1dc06..1b9ce8b 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,27 @@
Install the binary in `~/.local/bin` (it must be on your `PATH`). The commands below use `v0.4.0`, which exists only once that release is published; until then, use the latest tag on [GitHub Releases](https://github.com/BasisTI/taiga-cli/releases).
-Download the tarball for your platform together with `SHA256SUMS`; the binary is installed only if its checksum matches. The block runs in a subshell with `set -eu`, so any failing step (download, a missing or failed checksum, extraction) stops it before anything is installed, and your shell stays open. On macOS, where `sha256sum` may be missing, it uses `shasum -a 256`:
+One line, with `curl` (or `wget`) and no `gh`:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh
+```
+
+[`install.sh`](install.sh) is POSIX `sh` and never uses `sudo`. It picks the latest release (no GitHub API call), downloads the tarball for your OS and architecture (Linux or macOS, amd64 or arm64) with `SHA256SUMS`, installs only if the checksum matches, and finishes by running `taiga version`. Pass options after `sh -s --`, or use the environment variables:
+
+```sh
+# a specific version (with or without the "v") in another directory
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin"
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | TAIGA_VERSION=0.4.0 TAIGA_INSTALL_DIR="$HOME/bin" sh
+```
+
+It warns when the destination is not on your `PATH`. `TAIGA_INSTALL_BASE_URL` replaces the releases URL and exists only to test the installer against a local fixture (`scripts/test-install`); do not set it otherwise.
+
+Prefer to read the script first? Download `install.sh`, review it and run `sh install.sh`.
+
+### Manual install
+
+To do it by hand, download the tarball for your platform together with `SHA256SUMS`; the binary is installed only if its checksum matches. The block runs in a subshell with `set -eu`, so any failing step (download, a missing or failed checksum, extraction) stops it before anything is installed, and your shell stays open. On macOS, where `sha256sum` may be missing, it uses `shasum -a 256`:
```sh
(
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..015b485
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,195 @@
+#!/bin/sh
+# Install the taiga CLI from a GitHub release.
+#
+# curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh
+# curl -fsSL .../install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin"
+#
+# Options (the environment variable is used when the option is absent):
+# -v, --version VERSION release to install, with or without "v" (TAIGA_VERSION; default: latest)
+# -d, --dir DIR destination directory (TAIGA_INSTALL_DIR; default: ~/.local/bin)
+# -h, --help show this help
+#
+# TAIGA_INSTALL_BASE_URL replaces https://github.com/BasisTI/taiga-cli/releases and exists
+# only so the installer can be tested against a local fixture; do not set it otherwise.
+#
+# It never uses sudo.
+
+# Everything below sits in one brace group, so the shell runs nothing until it has
+# read the closing brace: a download cut at any point is a syntax error, not a
+# partial install.
+{
+ main() {
+ set -eu
+
+ repo_url="https://github.com/BasisTI/taiga-cli/releases"
+ base_url="${TAIGA_INSTALL_BASE_URL:-$repo_url}"
+ base_url="${base_url%/}"
+ version="${TAIGA_VERSION:-}"
+ dir="${TAIGA_INSTALL_DIR:-${HOME:-}/.local/bin}"
+
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ -v | --version)
+ [ $# -ge 2 ] || die "$1 needs a value"
+ version="$2"
+ shift 2
+ ;;
+ --version=*)
+ version="${1#--version=}"
+ shift
+ ;;
+ -d | --dir)
+ [ $# -ge 2 ] || die "$1 needs a value"
+ dir="$2"
+ shift 2
+ ;;
+ --dir=*)
+ dir="${1#--dir=}"
+ shift
+ ;;
+ -h | --help)
+ usage
+ return 0
+ ;;
+ *)
+ die "unknown option: $1 (try --help)"
+ ;;
+ esac
+ done
+
+ if [ -z "$dir" ] || [ "$dir" = "/.local/bin" ]; then
+ die "cannot work out the destination: set HOME, TAIGA_INSTALL_DIR or --dir"
+ fi
+ while [ "${#dir}" -gt 1 ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done
+
+ if [ -e "$dir/taiga" ] && [ ! -f "$dir/taiga" ]; then
+ die "$dir/taiga exists and is not a regular file; nothing installed"
+ fi
+
+ if command -v curl >/dev/null 2>&1; then
+ fetcher=curl
+ elif command -v wget >/dev/null 2>&1; then
+ fetcher=wget
+ else
+ die "curl or wget is required"
+ fi
+ if command -v sha256sum >/dev/null 2>&1; then
+ hasher="sha256sum"
+ elif command -v shasum >/dev/null 2>&1; then
+ hasher="shasum -a 256"
+ else
+ die "sha256sum or shasum is required to verify the download"
+ fi
+ command -v tar >/dev/null 2>&1 || die "tar is required"
+
+ case "$(uname -s)" in
+ Linux) os=linux ;;
+ Darwin) os=darwin ;;
+ *) die "unsupported operating system: $(uname -s) (supported: Linux, Darwin)" ;;
+ esac
+ case "$(uname -m)" in
+ x86_64 | amd64) arch=amd64 ;;
+ aarch64 | arm64) arch=arm64 ;;
+ *) die "unsupported architecture: $(uname -m) (supported: x86_64, arm64)" ;;
+ esac
+
+ if [ -z "$version" ]; then
+ say "Looking up the latest release"
+ version=$(latest_version)
+ [ -n "$version" ] || die "could not work out the latest release from $base_url/latest"
+ fi
+ version="${version#v}"
+ case "$version" in
+ '' | *[!0-9A-Za-z._+-]*) die "invalid version: $version" ;;
+ esac
+
+ file="taiga_${version}_${os}_${arch}.tar.gz"
+ url="$base_url/download/v$version"
+
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install.XXXXXX") || die "cannot create a temporary directory"
+ staged=""
+ trap 'cleanup' EXIT
+ trap 'exit 1' INT TERM HUP
+
+ say "Downloading taiga v$version for $os/$arch"
+ download "$url/$file" "$tmp/$file" || die "could not download $url/$file"
+ download "$url/SHA256SUMS" "$tmp/SHA256SUMS" || die "could not download $url/SHA256SUMS"
+
+ expected=$(awk -v f="$file" '$2 == f { print $1; exit }' "$tmp/SHA256SUMS")
+ [ -n "$expected" ] || die "$file is not listed in SHA256SUMS; nothing installed"
+ actual=$($hasher "$tmp/$file" | awk '{ print $1 }')
+ if [ "$expected" != "$actual" ]; then
+ die "checksum mismatch for $file (expected $expected, got $actual); nothing installed"
+ fi
+ say "Checksum OK"
+
+ tar -xzf "$tmp/$file" -C "$tmp" taiga || die "could not extract taiga from $file"
+ [ -f "$tmp/taiga" ] || die "$file does not contain a taiga binary"
+
+ mkdir -p "$dir" || die "cannot create $dir"
+ staged="$dir/.taiga.$$"
+ cp "$tmp/taiga" "$staged" || die "cannot write to $dir"
+ chmod 0755 "$staged"
+ mv -f "$staged" "$dir/taiga" || die "cannot install into $dir"
+ staged=""
+
+ say "Installed $dir/taiga"
+ "$dir/taiga" version &2
+ }
+
+ die() {
+ printf 'install.sh: %s\n' "$*" >&2
+ exit 1
+ }
+
+ cleanup() {
+ [ -z "${staged:-}" ] || rm -f "$staged"
+ [ -z "${tmp:-}" ] || rm -rf "$tmp"
+ }
+
+ download() {
+ if [ "$fetcher" = curl ]; then
+ curl -fsSL -o "$2" "$1"
+ else
+ wget -q -O "$2" "$1"
+ fi
+ }
+
+ # Prints the latest version from the redirect of /latest (…/tag/vX.Y.Z), without the GitHub API.
+ latest_version() {
+ if [ "$fetcher" = curl ]; then
+ location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$base_url/latest") || return 0
+ else
+ # No --max-redirect: BusyBox wget lacks it. Follow the redirect and read the
+ # first Location header from the -S output (GNU and BusyBox both print it).
+ location=$(wget -q -S -O /dev/null "$base_url/latest" 2>&1 | awk 'tolower($1) == "location:" { print $2; exit }' | tr -d '\r') || return 0
+ fi
+ tag="${location##*/}"
+ case "$tag" in
+ v[0-9]*) printf '%s\n' "${tag#v}" ;;
+ esac
+ }
+
+ main "$@"
+}
diff --git a/scripts/test-install b/scripts/test-install
new file mode 100755
index 0000000..5d6f8be
--- /dev/null
+++ b/scripts/test-install
@@ -0,0 +1,215 @@
+#!/bin/sh
+# Tests install.sh against a local fixture (no external network).
+# scripts/test-install [shell...] default shells: dash, sh
+# A fake release tree (tarball + SHA256SUMS built here) is served over HTTP on
+# 127.0.0.1, with /latest redirecting to /tag/v0.4.0 like github.com does.
+set -eu
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d "${TMPDIR:-/tmp}/taiga-install-test.XXXXXX")
+server_pid=""
+# shellcheck disable=SC2317,SC2329 # runs from the EXIT trap
+cleanup() {
+ [ -z "$server_pid" ] || kill "$server_pid" 2>/dev/null || true
+ rm -rf "$work"
+}
+trap cleanup EXIT
+trap 'exit 1' INT TERM HUP
+
+[ $# -gt 0 ] || set -- dash sh
+
+if command -v sha256sum >/dev/null 2>&1; then hasher="sha256sum"; else hasher="shasum -a 256"; fi
+
+# --- fixture ---------------------------------------------------------------
+site="$work/site"
+build="$work/build"
+mkdir -p "$build"
+cat > "$build/taiga" <<'BIN'
+#!/bin/sh
+echo "taiga version fixture"
+BIN
+chmod +x "$build/taiga"
+for v in 0.4.0 0.3.0; do
+ mkdir -p "$site/download/v$v"
+ for p in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64; do
+ tar -czf "$site/download/v$v/taiga_${v}_${p}.tar.gz" -C "$build" taiga
+ done
+ (cd "$site/download/v$v" && $hasher ./*.tar.gz | sed 's#\./##' > SHA256SUMS)
+done
+# v0.2.0: tarball exists but SHA256SUMS does not list it.
+mkdir -p "$site/download/v0.2.0"
+cp "$site/download/v0.4.0/taiga_0.4.0_linux_amd64.tar.gz" "$site/download/v0.2.0/taiga_0.2.0_linux_amd64.tar.gz"
+cp "$site/download/v0.4.0/taiga_0.4.0_linux_arm64.tar.gz" "$site/download/v0.2.0/taiga_0.2.0_linux_arm64.tar.gz"
+printf '%s other.tar.gz\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$site/download/v0.2.0/SHA256SUMS"
+# v0.1.0: SHA256SUMS lists a digest that does not match.
+mkdir -p "$site/download/v0.1.0"
+for p in linux_amd64 linux_arm64; do
+ cp "$site/download/v0.4.0/taiga_0.4.0_$p.tar.gz" "$site/download/v0.1.0/taiga_0.1.0_$p.tar.gz"
+ printf '%s taiga_0.1.0_%s.tar.gz\n' "1111111111111111111111111111111111111111111111111111111111111111" "$p" >> "$site/download/v0.1.0/SHA256SUMS"
+done
+
+cat > "$work/server.py" <<'PY'
+import http.server, os, sys
+
+site = sys.argv[1]
+
+class H(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *a, **k):
+ super().__init__(*a, directory=site, **k)
+ def do_GET(self):
+ if self.path == "/latest":
+ self.send_response(302)
+ self.send_header("Location", "/tag/v0.4.0")
+ self.end_headers()
+ return
+ if self.path.startswith("/tag/"):
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html")
+ self.end_headers()
+ self.wfile.write(b"release")
+ return
+ super().do_GET()
+ def log_message(self, *a):
+ pass
+
+s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
+print(s.server_address[1], flush=True)
+s.serve_forever()
+PY
+python3 "$work/server.py" "$site" > "$work/port" &
+server_pid=$!
+i=0
+while [ ! -s "$work/port" ]; do
+ i=$((i + 1)); [ $i -lt 100 ] || { echo "fixture server did not start" >&2; exit 1; }
+ sleep 0.1
+done
+port=$(cat "$work/port")
+export TAIGA_INSTALL_BASE_URL="http://127.0.0.1:$port"
+
+# Fake uname for the unsupported-platform cases.
+fake="$work/fakebin"
+mkdir -p "$fake"
+cat > "$fake/uname" <<'UN'
+#!/bin/sh
+case "$1" in
+ -s) echo "${FAKE_UNAME_S:-Linux}" ;;
+ -m) echo "${FAKE_UNAME_M:-x86_64}" ;;
+ *) echo "${FAKE_UNAME_S:-Linux}" ;;
+esac
+UN
+chmod +x "$fake/uname"
+
+# PATH with wget but without curl.
+nocurl="$work/nocurl"
+mkdir -p "$nocurl"
+for t in awk tr uname mktemp tar gzip wget sha256sum shasum mkdir cp chmod mv rm cat sed dirname; do
+ if p=$(command -v "$t" 2>/dev/null) && [ -x "$p" ]; then ln -s "$p" "$nocurl/$t"; fi
+done
+
+# --- harness ---------------------------------------------------------------
+fail=0
+n=0
+check() { # check "name" condition-exit-status
+ n=$((n + 1))
+ if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi
+}
+contains() { case "$1" in *"$2"*) return 0 ;; *) return 1 ;; esac; }
+
+run() { # run ; output in $out, status in $rc
+ sh_bin=$1; shift
+ rc=0
+ out=$("$sh_bin" "$root/install.sh" "$@" 2>&1 /dev/null 2>&1 || { echo "skip $sh_bin (not found)"; continue; }
+ echo "== $sh_bin"
+ unset TAIGA_VERSION TAIGA_INSTALL_DIR FAKE_UNAME_S FAKE_UNAME_M || true
+ d="$work/dest.$sh_bin"
+ rm -rf "$d"
+
+ # latest release, destination outside PATH
+ TAIGA_INSTALL_DIR="$d/a" run "$sh_bin"
+ if [ $rc -eq 0 ] && [ -x "$d/a/taiga" ] && contains "$out" "taiga version fixture" && contains "$out" "Installed $d/a/taiga" && contains "$out" "v0.4.0"; then check "latest install ($sh_bin)" 0; else check "latest install ($sh_bin)" 1; fi
+ if contains "$out" "is not on your PATH"; then check "warns when dir is not on PATH ($sh_bin)" 0; else check "warns when dir is not on PATH ($sh_bin)" 1; fi
+ if [ "$(find "$d/a" -mindepth 1 | wc -l)" -eq 1 ]; then check "no leftovers beside the binary ($sh_bin)" 0; else check "no leftovers beside the binary ($sh_bin)" 1; fi
+
+ # destination on PATH: no warning
+ mkdir -p "$d/onpath"
+ rc=0; out=$(PATH="$d/onpath:$PATH" TAIGA_INSTALL_DIR="$d/onpath/" "$sh_bin" "$root/install.sh" -v 0.3.0 2>&1 &1) || rc=$?
+ if [ $rc -eq 0 ] && [ -x "$d/f/taiga" ]; then check "piped to sh -s -- ($sh_bin)" 0; else check "piped to sh -s -- ($sh_bin)" 1; fi
+
+ # wget fallback (latest only; the pinned path uses the same download())
+ if [ -x "$nocurl/wget" ]; then
+ sh_path=$(command -v "$sh_bin")
+ rc=0; out=$(PATH="$nocurl" TAIGA_INSTALL_DIR="$d/w" "$sh_path" "$root/install.sh" 2>&1 "$d/a/taiga"
+ TAIGA_INSTALL_DIR="$d/a" run "$sh_bin"
+ if [ $rc -eq 0 ] && contains "$out" "taiga version fixture"; then check "overwrites an existing binary ($sh_bin)" 0; else check "overwrites an existing binary ($sh_bin)" 1; fi
+
+ # checksum mismatch
+ TAIGA_VERSION=0.1.0 TAIGA_INSTALL_DIR="$d/g" run "$sh_bin"
+ if [ $rc -ne 0 ] && contains "$out" "checksum mismatch" && [ ! -e "$d/g/taiga" ]; then check "checksum mismatch aborts ($sh_bin)" 0; else check "checksum mismatch aborts ($sh_bin)" 1; fi
+
+ # artifact missing from SHA256SUMS
+ TAIGA_VERSION=0.2.0 TAIGA_INSTALL_DIR="$d/h" run "$sh_bin"
+ if [ $rc -ne 0 ] && contains "$out" "not listed in SHA256SUMS" && [ ! -e "$d/h/taiga" ]; then check "artifact missing from SHA256SUMS aborts ($sh_bin)" 0; else check "artifact missing from SHA256SUMS aborts ($sh_bin)" 1; fi
+
+ # release that does not exist
+ TAIGA_VERSION=9.9.9 TAIGA_INSTALL_DIR="$d/i" run "$sh_bin"
+ if [ $rc -ne 0 ] && [ ! -e "$d/i/taiga" ]; then check "unknown release aborts ($sh_bin)" 0; else check "unknown release aborts ($sh_bin)" 1; fi
+
+ # unsupported platforms
+ rc=0; out=$(PATH="$fake:$PATH" FAKE_UNAME_S=FreeBSD TAIGA_INSTALL_DIR="$d/j" "$sh_bin" "$root/install.sh" 2>&1 &1 &1 &1) || rc=$?
+ if [ -e "$d/n" ]; then echo " truncated at $cut bytes installed something" >&2; trunc_ok=0; fi
+ done
+ if [ $trunc_ok -eq 1 ]; then check "truncated script installs nothing, at 9 cut points ($sh_bin)" 0; else check "truncated script installs nothing ($sh_bin)" 1; fi
+ rc=0; out=$(head -c "$total" "$root/install.sh" | TAIGA_INSTALL_DIR="$d/n" "$sh_bin" 2>&1) || rc=$?
+ if [ $rc -eq 0 ] && [ -x "$d/n/taiga" ]; then check "the complete script piped in installs ($sh_bin)" 0; else check "the complete script piped in installs ($sh_bin)" 1; fi
+
+ # destination taiga is a directory
+ mkdir -p "$d/o/taiga"
+ TAIGA_VERSION=0.3.0 TAIGA_INSTALL_DIR="$d/o" run "$sh_bin"
+ if [ $rc -ne 0 ] && contains "$out" "not a regular file" && [ "$(find "$d/o" -mindepth 1 | wc -l)" -eq 1 ]; then check "destination taiga is a directory ($sh_bin)" 0; else check "destination taiga is a directory ($sh_bin)" 1; fi
+done
+
+echo "$n checks, $([ $fail -eq 0 ] && echo passed || echo FAILED)"
+exit $fail
diff --git a/skills/taiga-cli/references/instalacao.md b/skills/taiga-cli/references/instalacao.md
index 0a992dd..deaff75 100644
--- a/skills/taiga-cli/references/instalacao.md
+++ b/skills/taiga-cli/references/instalacao.md
@@ -8,7 +8,26 @@ login e não edita a config do Codex: quando faltar algum destes passos, ele par
Os comandos usam a `v0.4.0`, que só existe depois de publicada a release; antes disso,
use a última tag de [Releases](https://github.com/BasisTI/taiga-cli/releases).
-Caminho principal: o tar.gz da release, conferido pelo `SHA256SUMS`. Ajuste `P` para a
+Caminho principal, uma linha, com `curl` (ou `wget`), sem `gh`:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh
+```
+
+O `install.sh` é `sh` POSIX e nunca usa `sudo`: descobre a última release (sem a API do
+GitHub), baixa o tar.gz da sua plataforma (Linux ou macOS, amd64 ou arm64) com o
+`SHA256SUMS`, só instala se o checksum bater e termina rodando `taiga version`. Versão e
+destino se fixam por opção ou por variável; ele avisa se o destino não está no `PATH`:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | sh -s -- --version v0.4.0 --dir "$HOME/bin"
+curl -fsSL https://raw.githubusercontent.com/BasisTI/taiga-cli/main/install.sh | TAIGA_VERSION=0.4.0 TAIGA_INSTALL_DIR="$HOME/bin" sh
+```
+
+`TAIGA_INSTALL_BASE_URL` existe só para testar o instalador contra uma fixture local; não
+a defina fora disso.
+
+Alternativa manual, o tar.gz da release conferido pelo `SHA256SUMS`. Ajuste `P` para a
plataforma (`linux_amd64`, `linux_arm64`, `darwin_amd64` ou `darwin_arm64`). O bloco roda
num subshell com `set -eu`: qualquer passo que falhe (download, checksum ausente ou
`FAILED`, extração) interrompe antes de instalar, sem fechar o seu shell. No macOS, sem
@@ -35,7 +54,7 @@ num subshell com `set -eu`: qualquer passo que falhe (download, checksum ausente
Sem `gh`, baixe o tar.gz e o `SHA256SUMS` pela página de releases para um diretório
vazio, entre nele e rode o mesmo bloco sem as linhas do `cd` e do `gh`.
-Alternativa, com Go instalado:
+Outra alternativa, com Go instalado:
```sh
GOBIN="$HOME/.local/bin" go install github.com/BasisTI/taiga-cli/cmd/taiga@v0.4.0