From ff1eef919535d940c30970206a1c353e2b825f14 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 29 Jul 2026 21:42:10 +0100 Subject: [PATCH 1/3] feat: add install.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script pins the version it was released alongside, so the common path makes no API call and cannot be rate limited: release-please rewrites DEFAULT_VERSION through the `extra-files` entry, whose Generic updater replaces the first semver token on any line carrying `x-release-please-version`. Only the token changes, so the leading v survives. Everything lives in functions with `main "$@"` last, so a truncated download cannot execute half a script. The strict curl flags apply to https URLs only — FLAGSMITH_CLI_BASE_URL exists to point the script at a local server for testing, and that server is plain http. PATH handling follows uv: an env script the startup files source, edited once each, skipped when the install dir is already on PATH. The env script lives under our own directory because $HOME/.local/bin/env belongs to cargo-dist and uv writes it. beep boop --- .github/workflows/release.yml | 18 +++ .pre-commit-config.yaml | 10 ++ README.md | 19 +++ install.sh | 259 ++++++++++++++++++++++++++++++++++ release-please-config.json | 5 +- 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30c143d..caca203 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,3 +49,21 @@ jobs: run: gh release edit "$GITHUB_REF_NAME" --prerelease=false --latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + install-script: + name: install.sh (${{ matrix.os }}) + needs: goreleaser + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: sh install.sh --version "$GITHUB_REF_NAME" --bin-dir "$RUNNER_TEMP/bin" + - name: the env script puts it on PATH + run: | + . "$HOME/.local/share/flagsmith/env" + flagsmith --version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d319a8..17f6954 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,16 @@ repos: - id: check-added-large-files - id: check-merge-conflict + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.13.1-1 + hooks: + - id: shfmt + - repo: https://github.com/golangci/golangci-lint rev: v2.11.4 hooks: diff --git a/README.md b/README.md index 366cb91..2879bbc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,25 @@ The next-generation Flagsmith command-line interface (work in progress). +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh +``` + +Installs to `$HOME/.local/bin` and adds it to your `PATH`. Options: + +```sh +curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh -s -- --version v2.0.0 --bin-dir /usr/local/bin --no-modify-path +curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/install.sh | sh -s -- --help +``` + +`FLAGSMITH_CLI_VERSION`, `FLAGSMITH_INSTALL_DIR` and `FLAGSMITH_NO_MODIFY_PATH` do the same if exported first. + +To pin the installer itself, fetch it at a commit you trust: `raw.githubusercontent.com/Flagsmith/flagsmith-cli//install.sh`. + +Alternatively, `go install github.com/Flagsmith/flagsmith-cli@latest`, or grab an archive from [Releases](https://github.com/Flagsmith/flagsmith-cli/releases). + ## Build ```sh diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..2fb4ff5 --- /dev/null +++ b/install.sh @@ -0,0 +1,259 @@ +#!/bin/sh +# Install the Flagsmith CLI. +# +# curl -fsSL https://get.flagsmith.com | sh +# curl -fsSL https://get.flagsmith.com | sh -s -- --version +# +# A variable assignment in front of `curl` applies to curl, not to sh, so pass +# the version as a flag or export it first. +set -eu + +DEFAULT_VERSION="v2.0.0-beta.1" # x-release-please-version + +REPO="Flagsmith/flagsmith-cli" +BIN_NAME="flagsmith" +BASE_URL="${FLAGSMITH_CLI_BASE_URL:-https://github.com/${REPO}/releases/download}" + +usage() { + cat < Version to install (default: ${DEFAULT_VERSION}) + --bin-dir Where to install (default: \$HOME/.local/bin) + --no-modify-path Leave shell startup files alone + --dry-run Report what would be installed, then stop + -h, --help Show this message + +Environment: + FLAGSMITH_CLI_VERSION Same as --version + FLAGSMITH_INSTALL_DIR Same as --bin-dir + FLAGSMITH_NO_MODIFY_PATH Set to 1 for --no-modify-path + FLAGSMITH_CLI_BASE_URL Release download base URL +EOF +} + +say() { printf '%s\n' "$*"; } +err() { + printf '%s\n' "install.sh: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "need '$1' (command not found)" +} + +download() { + if [ "$DOWNLOADER" = curl ]; then + case "$1" in + # Refuse a downgrade to http on our own URLs; a base URL someone set + # themselves is their call. + https://*) curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "$2" "$1" ;; + *) curl -fsSL --retry 3 -o "$2" "$1" ;; + esac + else + wget --quiet --output-document="$2" "$1" + fi +} + +parse_args() { + VERSION="${FLAGSMITH_CLI_VERSION:-$DEFAULT_VERSION}" + INSTALL_DIR="${FLAGSMITH_INSTALL_DIR:-}" + NO_MODIFY_PATH="${FLAGSMITH_NO_MODIFY_PATH:-0}" + DRY_RUN=0 + + while [ $# -gt 0 ]; do + case "$1" in + -v | --version) + [ $# -ge 2 ] || err "--version needs a value, e.g. --version ${DEFAULT_VERSION}" + VERSION="$2" + shift 2 + ;; + --bin-dir) + [ $# -ge 2 ] || err "--bin-dir needs a value" + INSTALL_DIR="$2" + shift 2 + ;; + --no-modify-path) + NO_MODIFY_PATH=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) err "unknown option '$1' (try --help)" ;; + esac + done + + case "$VERSION" in + v*) ;; + *) VERSION="v${VERSION}" ;; + esac + : "${INSTALL_DIR:=${HOME}/.local/bin}" +} + +# detect_platform sets OS and ARCH to the halves of a release archive name. +detect_platform() { + OS=$(uname -s) + ARCH=$(uname -m) + + case "$OS" in + Linux) OS=linux ;; + Darwin) OS=darwin ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + err "Windows is not supported by this script — download the .zip from https://github.com/${REPO}/releases" + ;; + *) err "unsupported operating system '${OS}'" ;; + esac + + case "$ARCH" in + x86_64 | amd64) ARCH=amd64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) err "unsupported architecture '${ARCH}' — 'go install github.com/${REPO}@${VERSION}' builds from source" ;; + esac + + # uname reports x86_64 under Rosetta. + if [ "$OS" = darwin ] && [ "$ARCH" = amd64 ] && + [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + ARCH=arm64 + fi +} + +verify_checksum() { + _archive="$1" + _name=$(basename "$_archive") + _matches=$(awk -v name="$_name" '$2 == name || $2 == "*" name {print $1}' "$2") + [ "$(printf '%s' "$_matches" | grep -c .)" = 1 ] || + err "expected exactly one checksum for ${_name} in checksums.txt" + + if command -v sha256sum >/dev/null 2>&1; then + _actual=$(sha256sum "$_archive" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + _actual=$(shasum -a 256 "$_archive" | awk '{print $1}') + elif command -v openssl >/dev/null 2>&1; then + _actual=$(openssl dgst -sha256 "$_archive" | awk '{print $NF}') + else + err "need 'sha256sum', 'shasum' or 'openssl' to verify the download" + fi + + [ "$_actual" = "$_matches" ] || + err "checksum mismatch for ${_name}: expected ${_matches}, got ${_actual}" +} + +# write_env_scripts writes the snippets the startup files source. They live +# under our own directory: $HOME/.local/bin/env belongs to cargo-dist. +write_env_scripts() { + mkdir -p "$(dirname "$ENV_SCRIPT")" + cat >"$ENV_SCRIPT" <"${ENV_SCRIPT}.fish" <>"$1" + say " updated $1" +} + +modify_path() { + case ":${PATH}:" in + *:"${INSTALL_DIR}":*) return 0 ;; + esac + + write_env_scripts + _line=". \"${ENV_SCRIPT}\"" + _edited=0 + for _rc in .profile .bashrc .bash_profile .bash_login .zshrc .zshenv; do + if [ -f "${HOME}/${_rc}" ]; then + add_source_line "${HOME}/${_rc}" "$_line" + _edited=1 + fi + done + if [ "$_edited" = 0 ]; then + printf '%s\n' "$_line" >>"${HOME}/.profile" + say " created ${HOME}/.profile" + fi + if [ -d "${HOME}/.config/fish" ]; then + mkdir -p "${HOME}/.config/fish/conf.d" + printf 'source "%s"\n' "${ENV_SCRIPT}.fish" >"${HOME}/.config/fish/conf.d/flagsmith.fish" + say " updated ${HOME}/.config/fish/conf.d/flagsmith.fish" + fi + PATH_MODIFIED=1 +} + +main() { + parse_args "$@" + + need_cmd uname + need_cmd tar + if command -v curl >/dev/null 2>&1; then + DOWNLOADER=curl + elif command -v wget >/dev/null 2>&1; then + DOWNLOADER=wget + else + err "need 'curl' or 'wget'" + fi + + detect_platform + ENV_SCRIPT="${XDG_DATA_HOME:-${HOME}/.local/share}/flagsmith/env" + PATH_MODIFIED=0 + + archive_name="${BIN_NAME}_${VERSION#v}_${OS}_${ARCH}.tar.gz" + archive_url="${BASE_URL}/${VERSION}/${archive_name}" + sums_url="${BASE_URL}/${VERSION}/checksums.txt" + + if [ "$DRY_RUN" = 1 ]; then + say "would install ${BIN_NAME} ${VERSION} (${OS}/${ARCH}) to ${INSTALL_DIR}" + say " archive: ${archive_url}" + say " checksums: ${sums_url}" + return 0 + fi + + tmp=$(mktemp -d 2>/dev/null || mktemp -d -t flagsmith) + trap 'rm -rf "$tmp"' EXIT INT TERM + + say "downloading ${BIN_NAME} ${VERSION} (${OS}/${ARCH})" + download "$archive_url" "${tmp}/${archive_name}" || + err "cannot download ${archive_url} +If ${VERSION} was released moments ago its archives may still be uploading — retry shortly, or choose a version with --version." + download "$sums_url" "${tmp}/checksums.txt" || err "cannot download ${sums_url}" + verify_checksum "${tmp}/${archive_name}" "${tmp}/checksums.txt" + + tar -xzf "${tmp}/${archive_name}" -C "$tmp" "$BIN_NAME" + mkdir -p "$INSTALL_DIR" + chmod 755 "${tmp}/${BIN_NAME}" + mv -f "${tmp}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}" + + installed=$("${INSTALL_DIR}/${BIN_NAME}" --version 2>/dev/null) || + err "${INSTALL_DIR}/${BIN_NAME} was installed but will not run — wrong platform?" + say "installed ${installed} to ${INSTALL_DIR}/${BIN_NAME}" + + [ "$NO_MODIFY_PATH" = 1 ] || modify_path + + say "" + if [ "$PATH_MODIFIED" = 1 ]; then + say "Run '. \"${ENV_SCRIPT}\"' or open a new shell, then '${BIN_NAME} init' to get started." + else + say "Run '${BIN_NAME} init' to get started." + fi +} + +main "$@" || exit 1 diff --git a/release-please-config.json b/release-please-config.json index 0d0a7c1..46ba24f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,7 +8,10 @@ "prerelease": true, "prerelease-type": "beta", "draft": false, - "include-component-in-tag": false + "include-component-in-tag": false, + "extra-files": [ + "install.sh" + ] } }, "changelog-sections": [ From 2d9e018c4513e757c5f281bef9714551953efcd3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 29 Jul 2026 22:26:21 +0100 Subject: [PATCH 2/3] feat: put the install dir on $GITHUB_PATH Each `run:` step gets a fresh shell, and an Actions shell is neither a login nor an interactive one, so nothing it starts reads the startup files the installer edited. Appending the directory to the file $GITHUB_PATH points at is the mechanism the runner does honour: it prepends those entries for every later step. So `curl ... | sh` in one step and `flagsmith` in the next now works on any runner, rather than only on images that already ship ~/.local/bin on PATH. Taken from uv's Add-Ci-Path. The release smoke job drops its explicit sourcing of the env script and relies on this instead. beep boop --- .github/workflows/release.yml | 5 +---- install.sh | 13 ++++++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caca203..cd2094d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,4 @@ jobs: with: persist-credentials: false - run: sh install.sh --version "$GITHUB_REF_NAME" --bin-dir "$RUNNER_TEMP/bin" - - name: the env script puts it on PATH - run: | - . "$HOME/.local/share/flagsmith/env" - flagsmith --version + - run: flagsmith --version diff --git a/install.sh b/install.sh index 2fb4ff5..d8c0769 100644 --- a/install.sh +++ b/install.sh @@ -166,6 +166,14 @@ end EOF } +# add_ci_path makes the CLI available to later steps of a GitHub Actions job. +# GITHUB_PATH does not expand variables, so write the resolved directory. +add_ci_path() { + [ -n "${GITHUB_PATH:-}" ] || return 0 + printf '%s\n' "$INSTALL_DIR" >>"$GITHUB_PATH" + say " added ${INSTALL_DIR} to \$GITHUB_PATH" +} + # add_source_line appends to a startup file, once. add_source_line() { grep -qF "$2" "$1" && return 0 @@ -246,7 +254,10 @@ If ${VERSION} was released moments ago its archives may still be uploading — r err "${INSTALL_DIR}/${BIN_NAME} was installed but will not run — wrong platform?" say "installed ${installed} to ${INSTALL_DIR}/${BIN_NAME}" - [ "$NO_MODIFY_PATH" = 1 ] || modify_path + if [ "$NO_MODIFY_PATH" != 1 ]; then + modify_path + add_ci_path + fi say "" if [ "$PATH_MODIFIED" = 1 ]; then From eca9bfe86127787ba1f2c826fe7e9579e3b5affc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 30 Jul 2026 12:38:55 +0100 Subject: [PATCH 3/3] fix: stop suppressing errexit for the whole install POSIX ignores -e for every command of an AND-OR list but the last, and that suppression covers the entire body of a function called there. So `main "$@" || exit 1` disabled set -e for the whole script: tar, mkdir, chmod and mv could all fail unnoticed. Reinstalling over an existing binary with a corrupt archive printed the success message and exited 0. The idiom came from rustup and cargo-dist, which set -u only and wrap every command in `ensure`, so the trailing `|| exit 1` costs them nothing. It is not transferable to a script that relies on -e. beep boop --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index d8c0769..6243ab3 100644 --- a/install.sh +++ b/install.sh @@ -267,4 +267,4 @@ If ${VERSION} was released moments ago its archives may still be uploading — r fi } -main "$@" || exit 1 +main "$@"