feat: install the CLI in one command from warmbly.com/cli.sh or cli.ps1, with checksum-verified archives, a Homebrew formula and Scoop manifest generated alongside them, a ghcr cli image, and a make cli-check that installs from a local mirror and proves a tampered checksum stops the install

This commit is contained in:
Matthew Meszaros
2026-09-04 20:14:38 -07:00
parent f46fc86a7c
commit 4e94d2b47b
11 changed files with 1382 additions and 5 deletions
+23
View File
@@ -35,6 +35,7 @@ jobs:
make: ${{ steps.filter.outputs.make }}
ios: ${{ steps.filter.outputs.ios }}
installer: ${{ steps.filter.outputs.installer }}
cli-installer: ${{ steps.filter.outputs.cli-installer }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -75,6 +76,12 @@ jobs:
- 'site/public/install.sh'
- 'site/public/install.sh.sha256'
- 'scripts/check-installer.sh'
cli-installer:
- 'site/public/cli.sh'
- 'site/public/cli.sh.sha256'
- 'site/public/cli.ps1'
- 'scripts/check-cli-installer.sh'
- 'scripts/build-cli.sh'
migrations-ci:
name: Migrations
@@ -249,6 +256,22 @@ jobs:
- name: Check the installer
run: ./scripts/check-installer.sh
cli-installer-ci:
name: CLI Installer CI
needs: changes
if: needs.changes.outputs.cli-installer == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# dash is what /bin/sh is on Debian and Ubuntu, which is what most people
# will pipe this into. Ubuntu runners already have shellcheck and pwsh.
- name: Install dash
run: sudo apt-get update && sudo apt-get install -y dash
- name: Check the CLI installer
run: ./scripts/check-cli-installer.sh
make-ci:
name: Make App CI
needs: changes
+97 -3
View File
@@ -36,7 +36,7 @@ jobs:
strategy:
fail-fast: false
matrix:
service: [backend, consumer, worker, forms, updater]
service: [backend, consumer, worker, forms, updater, cli]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -214,9 +214,46 @@ jobs:
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *)
# The `warmbly` CLI is a plain static binary, so it cross-compiles for every
# platform on one runner.
#
# Assets are named WITHOUT the version, so
# releases/latest/download/warmbly_linux_amd64.tar.gz always resolves. That is
# what lets the install script find the newest build with no GitHub API call,
# which matters because the unauthenticated API is rate limited and a curl
# installer that fails on a busy CI runner is not an installer.
build-cli:
name: Build CLI
needs: validate-tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Cross-compile and package
env:
VERSION: ${{ github.ref_name }}
COMMIT: ${{ github.sha }}
BUILT_AT: ${{ github.event.head_commit.timestamp }}
run: |
set -euo pipefail
./scripts/build-cli.sh dist
- name: Upload
uses: actions/upload-artifact@v4
with:
name: warmbly-cli
path: dist/
retention-days: 1
create-release:
name: Create GitHub Release
needs: [validate-tag, build-go, build-frontend, merge-native]
needs: [validate-tag, build-go, build-frontend, merge-native, build-cli]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -228,6 +265,12 @@ jobs:
with:
fetch-depth: 0
- name: Download the CLI binaries
uses: actions/download-artifact@v4
with:
name: warmbly-cli
path: /tmp/cli
# The installer verifies what it pulled against this file, so it is what
# makes "curl | sh" checkable after the fact rather than only before it.
# One line per service, because the thing that reads it is a POSIX shell.
@@ -284,6 +327,18 @@ jobs:
Add `--wizard` to be asked where each store lives, what is kept and
for how long, and how it is backed up.
## The warmbly CLI
```
curl -fsSL https://warmbly.com/cli.sh | sh # macOS, Linux
irm https://warmbly.com/cli.ps1 | iex # Windows
brew install warmbly/tap/warmbly # Homebrew
scoop install warmbly # Scoop
```
Or take an archive below and unpack it yourself; `checksums.txt`
verifies every one of them. Already installed? `warmbly upgrade`.
`images.json` below lists the manifest digest of every image in this
release. The installer checks what it pulled against it, and you can
too:
@@ -307,6 +362,7 @@ jobs:
| Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` |
| Forms | `${{ env.IMAGE_PREFIX }}/forms:${{ github.ref_name }}` |
| Updater | `${{ env.IMAGE_PREFIX }}/updater:${{ github.ref_name }}` |
| CLI | `${{ env.IMAGE_PREFIX }}/cli:${{ github.ref_name }}` |
## Deployment
@@ -314,12 +370,50 @@ jobs:
EOF
} > /tmp/release-body.md
# The formula and manifest are generated with the archives, so their
# checksums can never drift from what they describe. Pushing them is
# skipped, loudly, when the tap token is not configured: a release must
# not fail because a downstream package repo is not set up yet.
- name: Publish the Homebrew formula and Scoop manifest
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -z "${TAP_TOKEN:-}" ]; then
echo "HOMEBREW_TAP_TOKEN is not set; skipping the tap push."
echo "The formula and manifest are still attached to the release."
exit 0
fi
# A prerelease must never become the default `brew install`.
case "$TAG" in
*-*) echo "$TAG is a prerelease; not updating the taps."; exit 0 ;;
esac
git config --global user.name "warmbly-release"
git config --global user.email "release@warmbly.com"
git clone --depth 1 \
"https://x-access-token:${TAP_TOKEN}@github.com/warmbly/homebrew-tap.git" /tmp/tap
mkdir -p /tmp/tap/Formula /tmp/tap/bucket
cp /tmp/cli/warmbly.rb /tmp/tap/Formula/warmbly.rb
cp /tmp/cli/warmbly.json /tmp/tap/bucket/warmbly.json
cd /tmp/tap
git add Formula/warmbly.rb bucket/warmbly.json
if git diff --cached --quiet; then
echo "the tap already describes $TAG"
else
git commit -m "warmbly $TAG"
git push
echo "pushed warmbly $TAG to the tap"
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body_path: /tmp/release-body.md
files: /tmp/images.json
files: |
/tmp/images.json
/tmp/cli/*
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
+5
View File
@@ -15,6 +15,7 @@
/migrate
/updater
/warmblyctl
/warmbly
# Test binary, built with `go test -c`
*.test
@@ -28,6 +29,10 @@ profile.cov
# Dependency directories (remove the comment below to include it)
vendor/
# Local CLI builds (make warmbly / make warmbly-dist)
/bin/
/dist/
# Go workspace file
go.work
go.work.sum
+28 -1
View File
@@ -42,7 +42,7 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
restart restart-go restart-all infra infra-down app app-down app-logs \
backend forms forms-web consumer worker run dev tracking realtime web \
admin site docs grant-admin revoke-admin gen-key installer-sha installer-check installer-demo \
db-reset db-wipe migrate
db-reset db-wipe migrate warmbly warmbly-dist cli-sha cli-check
setup-tools:
@echo "Installing required Go tools into $(GO_BIN)"
@@ -50,6 +50,33 @@ setup-tools:
GOBIN=$(GO_BIN) go install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION)
GOBIN=$(GO_BIN) go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION)
# Build the `warmbly` CLI into ./bin, stamped with this checkout's version so
# `warmbly version` reports something meaningful. This is the customer CLI; the
# operator one (warmblyctl) ships in the backend image and runs there.
warmbly:
@mkdir -p bin
go build -ldflags="-s -w \
-X github.com/warmbly/warmbly/internal/version.Version=$(WARMBLY_BUILD_VERSION) \
-X github.com/warmbly/warmbly/internal/version.Commit=$(WARMBLY_BUILD_COMMIT) \
-X github.com/warmbly/warmbly/internal/version.BuiltAt=$(WARMBLY_BUILD_TIME)" \
-o bin/warmbly ./cmd/cli
@echo "built bin/warmbly ($(WARMBLY_BUILD_VERSION))"
@echo "put it on your PATH: sudo install -m 0755 bin/warmbly /usr/local/bin/warmbly"
# Everything a release publishes for the CLI: an archive per platform, the
# checksums, and the Homebrew and Scoop manifests. Same script the release
# workflow runs, so an artifact can be reproduced locally.
warmbly-dist:
./scripts/build-cli.sh dist
# The published installer at https://warmbly.com/cli.sh. Regenerate the
# checksum after any edit to it; CI fails when the two disagree.
cli-sha:
@cd site/public && sha256sum cli.sh > cli.sh.sha256 && cat cli.sh.sha256
cli-check:
@./scripts/check-cli-installer.sh
# Format all Go code. CI's golangci-lint enforces gofmt, so this is the
# formatting signal to run before committing, not `go build`.
fmt:
+6 -1
View File
@@ -32,7 +32,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=$CGO GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags "$TAGS" -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/backend ./cmd/backend; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/seed ./cmd/seed; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/migrate ./cmd/migrate; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmblyctl ./cmd/warmblyctl
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmblyctl ./cmd/warmblyctl; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmbly ./cmd/cli
# Runtime stage
FROM alpine:3.23
@@ -60,6 +61,10 @@ COPY --from=builder /out/migrate /app/migrate
# `docker compose exec backend warmblyctl status` and not a path.
COPY --from=builder /out/warmblyctl /usr/local/bin/warmblyctl
# The customer CLI ships alongside it, so an operator who has exec on the box
# can drive the product as well as recover it without installing anything.
COPY --from=builder /out/warmbly /usr/local/bin/warmbly
# Installer script the worker orchestrator uploads + runs over SSH, and serves
# at GET /worker-install.sh. The mode is explicit because COPY otherwise keeps
# the checkout's: on a filesystem without POSIX permissions that is 0700, and
+46
View File
@@ -0,0 +1,46 @@
# The `warmbly` CLI as an image, for CI jobs and anywhere installing a binary
# is more trouble than pulling one:
#
# docker run --rm -e WARMBLY_TOKEN ghcr.io/warmbly/warmbly/cli campaign list
#
# Distroless-style: the CLI is a static binary that talks to one HTTPS API, so
# the runtime needs certificates, timezone data and nothing else.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=""
ARG COMMIT=""
ARG BUILT_AT=""
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags="-s -w \
-X github.com/warmbly/warmbly/internal/version.Version=$VERSION \
-X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT \
-X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" \
-o /out/warmbly ./cmd/cli
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 1000 warmbly
COPY --from=builder /out/warmbly /usr/local/bin/warmbly
# A container has no browser, so `warmbly auth login` cannot finish here.
# WARMBLY_TOKEN is the documented way in, and the config directory is a volume
# mount point for anyone who would rather bring their hosts.yml.
ENV WARMBLY_CONFIG_DIR=/home/warmbly/.config/warmbly \
WARMBLY_NO_UPDATE_CHECK=1
USER warmbly
WORKDIR /home/warmbly
ENTRYPOINT ["warmbly"]
CMD ["--help"]
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
#
# Builds the `warmbly` CLI for every platform we publish, packages each one,
# and writes the manifests the package managers read.
#
# Run by the release workflow and by `make cli-dist`, so a release artifact can
# be reproduced locally byte for byte given the same VERSION and COMMIT.
#
# ./scripts/build-cli.sh dist
#
# Assets are named without the version on purpose: the install script resolves
# https://github.com/warmbly/warmbly/releases/latest/download/warmbly_<os>_<arch>.tar.gz
# with no GitHub API call, and the unauthenticated API's rate limit is exactly
# what breaks a curl installer on a shared CI runner.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=${1:-dist}
REPO=warmbly/warmbly
MODULE=github.com/warmbly/warmbly
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}
COMMIT=${COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "")}
BUILT_AT=${BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}
# Every platform the install script and the package managers know how to ask
# for. Keep this list and the one in site/public/cli.sh in step; the installer
# check verifies they agree.
PLATFORMS="darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"
LDFLAGS="-s -w
-X ${MODULE}/internal/version.Version=${VERSION}
-X ${MODULE}/internal/version.Commit=${COMMIT}
-X ${MODULE}/internal/version.BuiltAt=${BUILT_AT}"
rm -rf "$OUT"
mkdir -p "$OUT"
# Completions ship inside every archive so the install script can drop them in
# without running the binary it just downloaded, which it cannot do for a
# cross-platform install anyway.
stage_completions() {
local host_bin=$1 dest=$2
mkdir -p "$dest"
for shell in bash zsh fish powershell; do
"$host_bin" completion "$shell" > "$dest/warmbly.$shell" 2>/dev/null || true
done
}
echo "building warmbly ${VERSION}"
host_bin="$OUT/.host/warmbly"
mkdir -p "$OUT/.host"
# shellcheck disable=SC2086
go build -ldflags="$LDFLAGS" -o "$host_bin" ./cmd/cli
completions="$OUT/.completions"
stage_completions "$host_bin" "$completions"
for target in $PLATFORMS; do
os=${target%/*}
arch=${target#*/}
ext=""
if [ "$os" = "windows" ]; then ext=".exe"; fi
stage="$OUT/.stage/warmbly_${os}_${arch}"
mkdir -p "$stage"
echo " $os/$arch"
# shellcheck disable=SC2086
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -ldflags="$LDFLAGS" -o "$stage/warmbly${ext}" ./cmd/cli
cp LICENSE README.md "$stage/"
cp -r "$completions" "$stage/completions"
if [ "$os" = "windows" ]; then
(cd "$stage" && zip -qr "../../warmbly_${os}_${arch}.zip" .)
else
tar -czf "$OUT/warmbly_${os}_${arch}.tar.gz" -C "$stage" .
fi
done
rm -rf "$OUT/.stage" "$OUT/.host" "$OUT/.completions"
(cd "$OUT" && sha256sum warmbly_* > checksums.txt)
echo
cat "$OUT/checksums.txt"
# ─────────────────────────────────────────────────────────────────────────
# Package manager manifests
#
# Written here rather than by hand so the checksums in them can never drift
# from the archives they describe, which is the failure mode that makes a tap
# install fail for everyone at once.
# ─────────────────────────────────────────────────────────────────────────
sum_for() { awk -v f="$1" '$2 == f { print $1 }' "$OUT/checksums.txt"; }
BASE="https://github.com/${REPO}/releases/download/${VERSION}"
cat > "$OUT/warmbly.rb" <<EOF
# Homebrew formula for the Warmbly CLI. Generated by scripts/build-cli.sh;
# the release workflow pushes it to the warmbly/homebrew-tap repository.
class Warmbly < Formula
desc "Warmbly from the command line: campaigns, contacts, mailboxes, inbox"
homepage "https://warmbly.com"
version "${VERSION#v}"
license "Apache-2.0"
on_macos do
on_intel do
url "${BASE}/warmbly_darwin_amd64.tar.gz"
sha256 "$(sum_for warmbly_darwin_amd64.tar.gz)"
end
on_arm do
url "${BASE}/warmbly_darwin_arm64.tar.gz"
sha256 "$(sum_for warmbly_darwin_arm64.tar.gz)"
end
end
on_linux do
on_intel do
url "${BASE}/warmbly_linux_amd64.tar.gz"
sha256 "$(sum_for warmbly_linux_amd64.tar.gz)"
end
on_arm do
url "${BASE}/warmbly_linux_arm64.tar.gz"
sha256 "$(sum_for warmbly_linux_arm64.tar.gz)"
end
end
def install
bin.install "warmbly"
bash_completion.install "completions/warmbly.bash" => "warmbly"
zsh_completion.install "completions/warmbly.zsh" => "_warmbly"
fish_completion.install "completions/warmbly.fish" => "warmbly.fish"
end
test do
assert_match "warmbly", shell_output("#{bin}/warmbly version")
end
end
EOF
cat > "$OUT/warmbly.json" <<EOF
{
"version": "${VERSION#v}",
"description": "Warmbly from the command line: campaigns, contacts, mailboxes, inbox",
"homepage": "https://warmbly.com",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "${BASE}/warmbly_windows_amd64.zip",
"hash": "$(sum_for warmbly_windows_amd64.zip)"
},
"arm64": {
"url": "${BASE}/warmbly_windows_arm64.zip",
"hash": "$(sum_for warmbly_windows_arm64.zip)"
}
},
"bin": "warmbly.exe",
"checkver": {
"github": "https://github.com/${REPO}"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/${REPO}/releases/download/v\$version/warmbly_windows_amd64.zip"
},
"arm64": {
"url": "https://github.com/${REPO}/releases/download/v\$version/warmbly_windows_arm64.zip"
}
}
}
}
EOF
echo
echo "wrote $OUT/ (archives, checksums.txt, warmbly.rb, warmbly.json)"
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# Checks the CLI installer served at https://warmbly.com/cli.sh, and its
# Windows counterpart at /cli.ps1.
#
# Both are served verbatim out of site/public, so this runs against the exact
# bytes a `curl -fsSL https://warmbly.com/cli.sh | sh` executes:
#
# * it parses as POSIX sh, in dash and not only in bash
# * shellcheck has nothing to say about it
# * --help and --dry-run work with no network and no terminal, because that
# is how someone reads it before trusting it, and --dry-run writes nothing
# * a real install against a local mirror produces a working binary, puts it
# on PATH and writes completions
# * a tampered checksum stops the install rather than warning about it
# * --uninstall removes exactly what it wrote and nothing else
# * the platforms it will download match the ones we actually build
# * the published checksum matches, so "download, verify, read, run" verifies
set -euo pipefail
cd "$(dirname "$0")/.."
SCRIPT=site/public/cli.sh
PS_SCRIPT=site/public/cli.ps1
SUMFILE=site/public/cli.sh.sha256
fail() { printf '\n\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
pass() { printf '\033[32m✓\033[0m %s\n' "$*"; }
[[ -f $SCRIPT ]] || fail "$SCRIPT is missing"
[[ -f $PS_SCRIPT ]] || fail "$PS_SCRIPT is missing"
# The script is executed by whatever /bin/sh is on the machine, which on Debian
# and Ubuntu is dash. Checking with bash alone would let a bashism through to
# exactly the hosts this is aimed at.
if command -v dash >/dev/null 2>&1; then
dash -n "$SCRIPT" || fail "the installer is not valid POSIX sh (dash -n)"
pass "parses as POSIX sh"
else
sh -n "$SCRIPT" || fail "the installer does not parse"
pass "parses (dash not installed; POSIX check was approximate)"
fi
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -s sh "$SCRIPT" || fail "shellcheck found problems in the installer"
pass "shellcheck clean ($(shellcheck --version | awk '/^version:/ {print $2}'))"
else
echo "· shellcheck not installed; skipped"
fi
# --help must work before anything is set up, which is where an unbound
# variable under set -u would otherwise hide.
sh "$SCRIPT" --help >/dev/null || fail "--help failed"
pass "--help works"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# --dry-run reaches its end with no network and, above all, writes nothing.
HOME="$work/dryhome" SHELL=/bin/bash sh "$SCRIPT" --dry-run --no-color --dir "$work/dryhome/bin" >/dev/null 2>&1 \
|| fail "--dry-run failed"
[[ ! -e "$work/dryhome" ]] || fail "--dry-run created $work/dryhome; it must write nothing"
pass "--dry-run runs and writes nothing"
# Every platform the installer will ask for has to be one we build, and the
# other way round. A mismatch is a 404 for whoever runs it on that machine.
script_platforms=$(sed -n 's/^PLATFORMS="\(.*\)"$/\1/p' "$SCRIPT" | tr ' ' '\n' | sort)
build_platforms=$(sed -n 's/^PLATFORMS="\(.*\)"$/\1/p' scripts/build-cli.sh | tr ' ' '\n' | sed 's|/|_|' | grep -v '^windows' | sort)
if [[ "$script_platforms" != "$build_platforms" ]]; then
fail "cli.sh and scripts/build-cli.sh disagree about platforms:
installer builds for:
$script_platforms
release builds:
$build_platforms"
fi
pass "installer and release agree on platforms"
# ─────────────────────────────────────────────────────────────────────────
# A real install, against a mirror on disk. file:// keeps this offline, which
# is what lets it run in CI without reaching GitHub.
# ─────────────────────────────────────────────────────────────────────────
mirror="$work/mirror"
mkdir -p "$mirror" "$work/stage/completions"
# A stand-in for the real binary: the installer only needs something that runs
# and answers `version`, and building the real CLI here would make this check
# a minute slower for nothing.
cat > "$work/stage/warmbly" <<'STUB'
#!/bin/sh
[ "${1:-}" = version ] && echo "warmbly v0.0.0-test (test)" && exit 0
exit 0
STUB
chmod +x "$work/stage/warmbly"
echo "# completions" > "$work/stage/completions/warmbly.bash"
echo "# completions" > "$work/stage/completions/warmbly.zsh"
echo "# completions" > "$work/stage/completions/warmbly.fish"
cp LICENSE "$work/stage/" 2>/dev/null || echo license > "$work/stage/LICENSE"
host_os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$(uname -m)" in
x86_64|amd64) host_arch=amd64 ;;
arm64|aarch64) host_arch=arm64 ;;
*) host_arch=amd64 ;;
esac
asset="warmbly_${host_os}_${host_arch}.tar.gz"
tar -czf "$mirror/$asset" -C "$work/stage" .
( cd "$mirror" && sha256sum "$asset" > checksums.txt )
home="$work/home"
mkdir -p "$home"
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" \
--base-url "file://$mirror" --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "installing from a local mirror failed"
[[ -x "$home/bin/warmbly" ]] || fail "the installer did not produce $home/bin/warmbly"
[[ "$("$home/bin/warmbly" version)" == "warmbly v0.0.0-test (test)" ]] || fail "the installed binary does not run"
pass "installs a working binary from a mirror"
grep -q 'warmbly CLI installer' "$home/.bash_profile" 2>/dev/null || grep -q 'warmbly CLI installer' "$home/.bashrc" 2>/dev/null \
|| fail "the installer did not put the install directory on PATH"
pass "puts the install directory on PATH"
[[ -f "$home/.local/share/bash-completion/completions/warmbly" ]] || fail "no bash completions were written"
pass "writes shell completions"
# A second run must not append the PATH line again.
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" \
--base-url "file://$mirror" --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "the second install run failed"
occurrences=$(grep -c 'warmbly CLI installer' "$home/.bash_profile" 2>/dev/null || true)
[[ "${occurrences:-0}" -le 1 ]] || fail "re-running appended the PATH line again ($occurrences times)"
pass "re-running is idempotent"
# A tampered archive must stop the install, not warn about it.
bad="$work/badmirror"
mkdir -p "$bad"
cp "$mirror/$asset" "$bad/"
sed 's/^[0-9a-f]\{64\}/0000000000000000000000000000000000000000000000000000000000000000/' \
"$mirror/checksums.txt" > "$bad/checksums.txt"
badhome="$work/badhome"
if HOME="$badhome" sh "$SCRIPT" --base-url "file://$bad" --dir "$badhome/bin" --no-color >/dev/null 2>&1; then
fail "a checksum mismatch did not stop the install"
fi
[[ ! -e "$badhome/bin/warmbly" ]] || fail "a checksum mismatch still installed the binary"
pass "refuses to install on a checksum mismatch"
# --uninstall removes what it wrote, and leaves the credentials alone.
mkdir -p "$home/.config/warmbly"
echo "token" > "$home/.config/warmbly/hosts.yml"
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" --uninstall --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "--uninstall failed"
[[ ! -e "$home/bin/warmbly" ]] || fail "--uninstall left the binary behind"
[[ ! -e "$home/.local/share/bash-completion/completions/warmbly" ]] || fail "--uninstall left completions behind"
[[ -f "$home/.config/warmbly/hosts.yml" ]] || fail "--uninstall removed the credentials; it must not"
pass "--uninstall removes the binary and completions, and keeps credentials"
# ─────────────────────────────────────────────────────────────────────────
# The Windows half. Ubuntu runners ship pwsh, so this is a real parse there.
# ─────────────────────────────────────────────────────────────────────────
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -Command "
\$errors = \$null
[System.Management.Automation.Language.Parser]::ParseFile('$PWD/$PS_SCRIPT', [ref]\$null, [ref]\$errors) | Out-Null
if (\$errors) { \$errors | ForEach-Object { Write-Host \$_ }; exit 1 }
" || fail "$PS_SCRIPT does not parse as PowerShell"
pass "cli.ps1 parses as PowerShell"
else
echo "· pwsh not installed; skipped the PowerShell parse"
fi
# ─────────────────────────────────────────────────────────────────────────
# The published checksum, which is what makes "download, verify, read, run" a
# real alternative to piping into a shell.
# ─────────────────────────────────────────────────────────────────────────
[[ -f $SUMFILE ]] || fail "$SUMFILE is missing. Run: make cli-sha"
( cd site/public && sha256sum -c "$(basename "$SUMFILE")" >/dev/null ) \
|| fail "$SUMFILE does not match $SCRIPT. Run: make cli-sha"
pass "published checksum matches"
printf '\n\033[32mAll CLI installer checks passed.\033[0m\n'
+266
View File
@@ -0,0 +1,266 @@
<#
.SYNOPSIS
Installs the warmbly CLI on Windows.
.DESCRIPTION
One static binary, no toolchain, no admin rights. Downloads the archive for
this machine's architecture from the GitHub release, checks it against the
published checksum, unpacks it into a per-user directory and puts that
directory on the user PATH.
Re-running it upgrades in place.
.EXAMPLE
irm https://warmbly.com/cli.ps1 | iex
.EXAMPLE
& ([scriptblock]::Create((irm https://warmbly.com/cli.ps1))) -Version v1.4.0
.EXAMPLE
& ([scriptblock]::Create((irm https://warmbly.com/cli.ps1))) -Uninstall
.LINK
https://docs.warmbly.com/api/cli/
#>
[CmdletBinding()]
param(
# Where the binary goes. Defaults to a per-user directory so nothing here
# needs an elevated shell.
[string]$Dir = $env:WARMBLY_INSTALL_DIR,
# A release tag to pin, for example v1.4.0. Defaults to the newest release.
[string]$Version = $env:WARMBLY_CLI_VERSION,
# Download from a mirror of the release assets instead of GitHub, for an
# egress-restricted network.
[string]$BaseUrl = $env:WARMBLY_CLI_BASE_URL,
# Leave the user PATH alone.
[switch]$NoModifyPath,
# Print what would happen and change nothing.
[switch]$DryRun,
# Remove the binary and its PATH entry.
[switch]$Uninstall,
# Reinstall even when the version already matches.
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$Repo = 'warmbly/warmbly'
$Releases = "https://github.com/$Repo/releases"
$Docs = 'https://docs.warmbly.com/api/cli/'
function Write-Step { param($m) Write-Host "> $m" -ForegroundColor Cyan }
function Write-Ok { param($m) Write-Host "$m" -ForegroundColor Green }
function Write-Warn { param($m) Write-Host "! $m" -ForegroundColor Yellow }
function Write-Fail { param($m) Write-Host "$m" -ForegroundColor Red; exit 1 }
# Windows on ARM runs amd64 binaries under emulation, but a native build is
# published, so the architecture is read rather than assumed.
function Get-Arch {
$arch = $env:PROCESSOR_ARCHITECTURE
if ($env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 }
switch ($arch) {
'AMD64' { return 'amd64' }
'ARM64' { return 'arm64' }
default {
Write-Fail @"
No published build for $arch.
We publish amd64 and arm64. Build from source with:
go install github.com/$Repo/cmd/cli@latest
"@
}
}
}
function Get-AssetUrl {
param($Name)
if ($BaseUrl) { return "$($BaseUrl.TrimEnd('/'))/$Name" }
if ($Version) { return "$Releases/download/$Version/$Name" }
return "$Releases/latest/download/$Name"
}
function Get-InstallDir {
if ($Dir) { return $Dir }
return (Join-Path $env:LOCALAPPDATA 'Warmbly\bin')
}
# The user PATH is read from the registry rather than from $env:PATH, because
# the session copy already has machine entries merged in and writing that back
# would move machine-wide entries into the user scope.
function Add-ToUserPath {
param($Target)
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($null -eq $current) { $current = '' }
$entries = $current -split ';' | Where-Object { $_ -ne '' }
if ($entries -contains $Target) {
Write-Ok "$Target is already on your PATH"
return
}
if ($NoModifyPath) {
Write-Warn "$Target is not on your PATH. Add it yourself, or re-run without -NoModifyPath."
return
}
if ($DryRun) {
Write-Host " would add $Target to the user PATH"
return
}
$updated = (@($entries) + $Target) -join ';'
[Environment]::SetEnvironmentVariable('Path', $updated, 'User')
# The registry change reaches new processes only, so this session gets the
# entry too. Without it, the very next command in this window fails.
$env:Path = "$env:Path;$Target"
Write-Ok "added $Target to your PATH"
Write-Warn 'Open a new terminal for other programs to see it.'
}
function Install-Completions {
param($Source)
$completion = Join-Path $Source 'completions\warmbly.powershell'
if (-not (Test-Path $completion)) { return }
$profilePath = $PROFILE.CurrentUserAllHosts
$marker = '# Added by the warmbly CLI installer'
if ($DryRun) {
Write-Host " would add completions to $profilePath"
return
}
if ((Test-Path $profilePath) -and (Select-String -Path $profilePath -Pattern ([regex]::Escape($marker)) -Quiet)) {
return
}
$dest = Join-Path (Get-InstallDir) 'warmbly.completion.ps1'
Copy-Item $completion $dest -Force
New-Item -ItemType Directory -Force -Path (Split-Path $profilePath) | Out-Null
Add-Content -Path $profilePath -Value "`n$marker`n. `"$dest`""
Write-Ok "wrote completions and referenced them from $profilePath"
}
function Invoke-Uninstall {
$target = Get-InstallDir
$exe = Join-Path $target 'warmbly.exe'
$removed = $false
if (Test-Path $exe) {
if ($DryRun) { Write-Host "would remove $exe" }
else { Remove-Item $exe -Force; Write-Ok "removed $exe" }
$removed = $true
}
$completion = Join-Path $target 'warmbly.completion.ps1'
if (Test-Path $completion) {
if (-not $DryRun) { Remove-Item $completion -Force }
$removed = $true
}
if (-not $DryRun) {
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($current) {
$kept = $current -split ';' | Where-Object { $_ -ne '' -and $_ -ne $target }
[Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User')
}
}
if (-not $removed) { Write-Warn "nothing to remove: no warmbly.exe in $target" }
$config = Join-Path $env:APPDATA 'warmbly'
if (Test-Path $config) {
Write-Host ''
Write-Host "Your sign-ins are still in $config."
Write-Host "Remove them with: Remove-Item -Recurse '$config'"
}
}
function Invoke-Install {
$arch = Get-Arch
$target = Get-InstallDir
$asset = "warmbly_windows_$arch.zip"
Write-Step 'Installing the warmbly CLI'
Write-Host " platform: windows/$arch"
Write-Host " version: $(if ($Version) { $Version } else { 'latest' })"
Write-Host " into: $target"
Write-Host ''
$exe = Join-Path $target 'warmbly.exe'
if ((Test-Path $exe) -and $Version -and -not $Force) {
$current = (& $exe version 2>$null | Select-Object -First 1) -split ' ' | Select-Object -Index 1
if ($current -eq $Version) {
Write-Ok "warmbly $current is already installed in $target"
return
}
}
if ($DryRun) {
Write-Host "would download $(Get-AssetUrl $asset)"
Write-Host "would verify it against $(Get-AssetUrl 'checksums.txt')"
Write-Host "would install $exe"
Install-Completions ''
Add-ToUserPath $target
return
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("warmbly-" + [guid]::NewGuid())
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
Write-Step "Downloading $asset"
$zip = Join-Path $tmp $asset
try {
Invoke-WebRequest -Uri (Get-AssetUrl $asset) -OutFile $zip -UseBasicParsing
} catch {
Write-Fail "could not download $(Get-AssetUrl $asset)`nIf you pinned -Version, check the tag exists: $Releases"
}
# The checksum is why this is safer than a bare download: a truncated
# transfer and a tampered one are indistinguishable to Expand-Archive.
try {
$sums = Join-Path $tmp 'checksums.txt'
Invoke-WebRequest -Uri (Get-AssetUrl 'checksums.txt') -OutFile $sums -UseBasicParsing
$want = (Select-String -Path $sums -Pattern ([regex]::Escape($asset)) | Select-Object -First 1).Line -split '\s+' | Select-Object -First 1
$got = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if (-not $want) {
Write-Warn "checksums.txt has no entry for $asset; continuing without verification"
} elseif ($want.ToLower() -ne $got) {
Write-Fail "checksum mismatch for $asset.`n expected $want`n got $got`nNothing was installed."
} else {
Write-Ok 'checksum verified'
}
} catch {
Write-Warn 'could not fetch checksums.txt; continuing without verification'
}
Write-Step 'Unpacking'
$unpacked = Join-Path $tmp 'x'
Expand-Archive -Path $zip -DestinationPath $unpacked -Force
$source = Join-Path $unpacked 'warmbly.exe'
if (-not (Test-Path $source)) { Write-Fail 'the archive did not contain warmbly.exe' }
New-Item -ItemType Directory -Force -Path $target | Out-Null
Copy-Item $source $exe -Force
$installed = (& $exe version 2>$null | Select-Object -First 1)
Write-Ok "installed $installed to $exe"
Install-Completions $unpacked
Add-ToUserPath $target
Write-Host ''
Write-Host 'Next: warmbly auth login'
Write-Host "Docs: $Docs"
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
}
if ($Uninstall) { Invoke-Uninstall } else { Invoke-Install }
+548
View File
@@ -0,0 +1,548 @@
#!/bin/sh
#
# curl -fsSL https://warmbly.com/cli.sh | sh
#
# Installs the `warmbly` CLI on this machine: one static binary, no Go
# toolchain, no package manager, no root. It downloads the archive for your
# platform from the GitHub release, checks it against the published checksum,
# and puts the binary somewhere on your PATH.
#
# sh cli.sh --help every flag, and the environment variable for each
# sh cli.sh --dry-run print exactly what it would do, touch nothing
# sh cli.sh --uninstall remove the binary and the completions it wrote
#
# What it does, in full:
#
# * detects your OS and CPU, and stops with a real message if we publish no
# build for it rather than downloading something that cannot run
# * resolves the newest release (or the one you pin with --version)
# * downloads warmbly_<os>_<arch>.tar.gz and checksums.txt, and REFUSES to
# install if the two disagree
# * installs to ~/.local/bin by default, which needs no sudo. Nothing else on
# your system is touched
# * writes shell completions, and tells you the one line to add to your shell
# profile if the install directory is not already on PATH
#
# Re-running it upgrades in place and says so when there is nothing to do.
#
# Verify before running, if you would rather:
#
# curl -fsSLO https://warmbly.com/cli.sh
# curl -fsSLO https://warmbly.com/cli.sh.sha256
# sha256sum -c cli.sh.sha256
# less cli.sh && sh cli.sh
#
# https://docs.warmbly.com/api/cli/
set -eu
# ─────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────
REPO="warmbly/warmbly"
BIN="warmbly"
DOCS="https://docs.warmbly.com/api/cli/"
RELEASES="https://github.com/${REPO}/releases"
# Every platform scripts/build-cli.sh publishes. The two lists have to agree:
# a platform here with no archive downloads a 404, and one missing here is a
# build nobody can install.
PLATFORMS="darwin_amd64 darwin_arm64 linux_amd64 linux_arm64"
# ─────────────────────────────────────────────────────────────────────────
# Options. Every one is also an environment variable, so the same install runs
# from Ansible, cloud-init, a Dockerfile or an agent with no keyboard.
# ─────────────────────────────────────────────────────────────────────────
DIR=${WARMBLY_INSTALL_DIR:-}
VERSION=${WARMBLY_CLI_VERSION:-}
# Where the archives come from. Overridable so an air-gapped or
# egress-restricted network can mirror the release assets internally and still
# use this exact script.
BASE_URL=${WARMBLY_CLI_BASE_URL:-}
NO_MODIFY_PATH=${WARMBLY_NO_MODIFY_PATH:-}
NO_COMPLETIONS=${WARMBLY_NO_COMPLETIONS:-}
DRY_RUN=""
UNINSTALL=""
FORCE=""
QUIET=""
USE_COLOR=1
# ─────────────────────────────────────────────────────────────────────────
# Output
# ─────────────────────────────────────────────────────────────────────────
setup_colors() {
if [ -n "$USE_COLOR" ] && [ -t 2 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET=$(printf '\033[0m')
C_DIM=$(printf '\033[2m')
C_BOLD=$(printf '\033[1m')
C_RED=$(printf '\033[31m')
C_GREEN=$(printf '\033[32m')
C_YELLOW=$(printf '\033[33m')
C_CYAN=$(printf '\033[36m')
else
C_RESET=""; C_DIM=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_CYAN=""
fi
}
say() { [ -n "$QUIET" ] || printf '%s\n' "$*" >&2; }
step() { [ -n "$QUIET" ] || printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$*" >&2; }
ok() { [ -n "$QUIET" ] || printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2; }
warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
die() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
usage() {
cat <<EOF
Install the warmbly CLI.
Usage:
curl -fsSL https://warmbly.com/cli.sh | sh
curl -fsSL https://warmbly.com/cli.sh | sh -s -- [flags]
Flags:
--dir PATH Where to install the binary (WARMBLY_INSTALL_DIR)
Default: \$HOME/.local/bin
--version VERSION Install a specific release tag (WARMBLY_CLI_VERSION)
Default: the newest release
--base-url URL Download from a mirror of the release
assets instead of GitHub (WARMBLY_CLI_BASE_URL)
--no-modify-path Never touch a shell profile (WARMBLY_NO_MODIFY_PATH)
--no-completions Do not write shell completions (WARMBLY_NO_COMPLETIONS)
--force Reinstall even if the version already matches
--uninstall Remove the binary and its completions
--dry-run Print what would happen, change nothing
--quiet Only errors
--no-color Never colourise output
--help This
Examples:
curl -fsSL https://warmbly.com/cli.sh | sh
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --dir /usr/local/bin
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --version v1.4.0
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --uninstall
After installing, sign in:
warmbly auth login
Docs: ${DOCS}
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case $1 in
--dir) shift; [ $# -gt 0 ] || die "--dir needs a path"; DIR=$1 ;;
--dir=*) DIR=${1#*=} ;;
--version) shift; [ $# -gt 0 ] || die "--version needs a release tag"; VERSION=$1 ;;
--version=*) VERSION=${1#*=} ;;
--base-url) shift; [ $# -gt 0 ] || die "--base-url needs a URL"; BASE_URL=$1 ;;
--base-url=*) BASE_URL=${1#*=} ;;
--no-modify-path) NO_MODIFY_PATH=1 ;;
--no-completions) NO_COMPLETIONS=1 ;;
--force) FORCE=1 ;;
--uninstall) UNINSTALL=1 ;;
--dry-run) DRY_RUN=1 ;;
--quiet|-q) QUIET=1 ;;
--no-color) USE_COLOR="" ;;
--help|-h) usage; exit 0 ;;
*) usage >&2; die "unknown option $1" ;;
esac
shift
done
}
# ─────────────────────────────────────────────────────────────────────────
# Platform
# ─────────────────────────────────────────────────────────────────────────
detect_platform() {
os=$(uname -s 2>/dev/null || echo unknown)
arch=$(uname -m 2>/dev/null || echo unknown)
case $os in
Linux) OS=linux ;;
Darwin) OS=darwin ;;
MINGW*|MSYS*|CYGWIN*)
die "this script installs the Unix build.
On Windows run this in PowerShell instead:
irm https://warmbly.com/cli.ps1 | iex" ;;
*) die "no published build for $os. Build from source with: go install github.com/${REPO}/cmd/cli@latest" ;;
esac
case $arch in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) die "no published build for $arch on $OS.
We publish amd64 and arm64. Build from source with:
go install github.com/${REPO}/cmd/cli@latest" ;;
esac
TARGET="${OS}_${ARCH}"
for known in $PLATFORMS; do
if [ "$known" = "$TARGET" ]; then
return 0
fi
done
die "no published build for $TARGET"
}
# fetch writes a URL to a file. curl and wget are both accepted because a
# minimal container image has exactly one of them and it is never the one you
# assumed.
fetch() {
url=$1
dest=$2
if [ -n "$DOWNLOADER" ] && [ "$DOWNLOADER" = curl ]; then
curl -fsSL --retry 3 --retry-delay 1 -o "$dest" "$url"
else
wget -q -O "$dest" "$url"
fi
}
require_downloader() {
if command -v curl >/dev/null 2>&1; then
DOWNLOADER=curl
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER=wget
else
die "neither curl nor wget is installed, so there is nothing to download with"
fi
}
# sha256_of prints a file's checksum with whichever tool the host has. macOS
# ships shasum, Linux ships sha256sum, Alpine ships both or neither.
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$1" | awk '{print $NF}'
else
echo ""
fi
}
# ─────────────────────────────────────────────────────────────────────────
# Install directory
# ─────────────────────────────────────────────────────────────────────────
# A literal tilde is what this matches: someone who typed --dir '~/bin' inside
# quotes meant their home directory, not a folder called "~".
# shellcheck disable=SC2088
expand_tilde() {
case $DIR in
"~/"*) DIR="${HOME}/${DIR#\~/}" ;;
esac
}
# resolve_dir picks where the binary goes. ~/.local/bin is the default because
# it needs no sudo and is on PATH by default on most modern distributions;
# piping an installer into a shell should never need root.
resolve_dir() {
if [ -z "$DIR" ]; then
DIR="${HOME:-/root}/.local/bin"
fi
expand_tilde
}
# on_path answers whether DIR is already searched, so we only talk about shell
# profiles when there is a real problem to solve.
on_path() {
case ":${PATH}:" in
*":${DIR}:"*) return 0 ;;
*) return 1 ;;
esac
}
# profile_file is the file a login shell reads, chosen from $SHELL rather than
# the shell running this script: this runs under sh no matter what the person
# actually uses.
profile_file() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;;
bash)
if [ -f "$HOME/.bashrc" ]; then
printf '%s' "$HOME/.bashrc"
else
printf '%s' "$HOME/.bash_profile"
fi ;;
fish) printf '%s' "$HOME/.config/fish/config.fish" ;;
*) printf '%s' "$HOME/.profile" ;;
esac
}
# The single quotes below are the point: $PATH has to reach the profile
# unexpanded, so it still resolves every time the shell reads it.
# shellcheck disable=SC2016
path_line() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
fish) printf 'fish_add_path %s' "$DIR" ;;
*) printf 'export PATH="%s:$PATH"' "$DIR" ;;
esac
}
# ensure_on_path appends the PATH line to the right profile, once. The marker
# comment is what makes a second run a no-op instead of a growing file.
ensure_on_path() {
if on_path; then
return 0
fi
line=$(path_line)
if [ -n "$NO_MODIFY_PATH" ]; then
warn "$DIR is not on your PATH. Add this yourself:"
say " $line"
return 0
fi
profile=$(profile_file)
if [ -n "$DRY_RUN" ]; then
say " would add to $profile: $line"
return 0
fi
if [ -f "$profile" ] && grep -q "warmbly CLI" "$profile" 2>/dev/null; then
ok "$profile already has the PATH line"
else
mkdir -p "$(dirname "$profile")"
{
printf '\n# Added by the warmbly CLI installer\n'
printf '%s\n' "$line"
} >> "$profile"
ok "added $DIR to your PATH in $profile"
fi
warn "open a new terminal, or run: $line"
}
# ─────────────────────────────────────────────────────────────────────────
# Completions
# ─────────────────────────────────────────────────────────────────────────
# completion_dir is where the shell looks without any configuration. When there
# is no such place we say nothing rather than writing a file that is never read.
completion_dir() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
bash)
if [ -d "$HOME/.local/share/bash-completion/completions" ] || [ "$1" = create ]; then
printf '%s' "$HOME/.local/share/bash-completion/completions/warmbly"
fi ;;
zsh)
printf '%s' "${ZDOTDIR:-$HOME}/.zfunc/_warmbly" ;;
fish)
printf '%s' "$HOME/.config/fish/completions/warmbly.fish" ;;
*) printf '' ;;
esac
}
install_completions() {
if [ -n "$NO_COMPLETIONS" ]; then
return 0
fi
shell_name=$(basename "${SHELL:-sh}")
src=""
case $shell_name in
bash) src="$1/completions/warmbly.bash" ;;
zsh) src="$1/completions/warmbly.zsh" ;;
fish) src="$1/completions/warmbly.fish" ;;
*) return 0 ;;
esac
dest=$(completion_dir create)
[ -n "$dest" ] || return 0
# The dry run has no unpacked archive to copy from, so it reports the
# destination rather than testing for a source that cannot exist yet.
if [ -n "$DRY_RUN" ]; then
say " would write $shell_name completions to $dest"
return 0
fi
[ -f "$src" ] || return 0
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
ok "wrote $shell_name completions to $dest"
if [ "$shell_name" = zsh ]; then
say " ${C_DIM}zsh needs ~/.zfunc on its fpath: add \`fpath+=~/.zfunc\` above compinit${C_RESET}"
fi
return 0
}
# ─────────────────────────────────────────────────────────────────────────
# Uninstall
# ─────────────────────────────────────────────────────────────────────────
do_uninstall() {
resolve_dir
target="$DIR/$BIN"
removed=""
if [ -f "$target" ]; then
if [ -n "$DRY_RUN" ]; then
say "would remove $target"
else
rm -f "$target"
ok "removed $target"
fi
removed=1
fi
for c in "$HOME/.local/share/bash-completion/completions/warmbly" \
"${ZDOTDIR:-$HOME}/.zfunc/_warmbly" \
"$HOME/.config/fish/completions/warmbly.fish"; do
if [ -f "$c" ]; then
if [ -n "$DRY_RUN" ]; then
say "would remove $c"
else
rm -f "$c"
ok "removed $c"
fi
removed=1
fi
done
if [ -z "$removed" ]; then
warn "nothing to remove: no warmbly found in $DIR"
fi
# Deliberately left alone: it holds the credentials, and someone
# reinstalling in a minute should not have to sign in again.
if [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/warmbly" ]; then
say ""
say "Your sign-ins are still in ${XDG_CONFIG_HOME:-$HOME/.config}/warmbly."
say "Remove them with: rm -rf ${XDG_CONFIG_HOME:-$HOME/.config}/warmbly"
fi
return 0
}
# ─────────────────────────────────────────────────────────────────────────
# Install
# ─────────────────────────────────────────────────────────────────────────
# archive_url builds the download URL. The version-less asset names are what
# let "latest" resolve with no GitHub API call, so the install works on a CI
# runner whose IP has already spent the unauthenticated rate limit.
archive_url() {
name=$1
if [ -n "$BASE_URL" ]; then
printf '%s/%s' "${BASE_URL%/}" "$name"
elif [ -n "$VERSION" ]; then
printf '%s/download/%s/%s' "$RELEASES" "$VERSION" "$name"
else
printf '%s/latest/download/%s' "$RELEASES" "$name"
fi
}
installed_version() {
if [ -x "$DIR/$BIN" ]; then
"$DIR/$BIN" version 2>/dev/null | head -1 | awk '{print $2}'
fi
}
do_install() {
detect_platform
resolve_dir
archive="warmbly_${TARGET}.tar.gz"
url=$(archive_url "$archive")
sums_url=$(archive_url "checksums.txt")
step "Installing the warmbly CLI"
say " platform: ${OS}/${ARCH}"
say " version: ${VERSION:-latest}"
say " into: ${DIR}"
say ""
current=$(installed_version)
if [ -n "$current" ] && [ -z "$FORCE" ] && [ -n "$VERSION" ] && [ "$current" = "$VERSION" ]; then
ok "warmbly $current is already installed in $DIR"
say " ${C_DIM}--force reinstalls it anyway${C_RESET}"
return 0
fi
if [ -n "$DRY_RUN" ]; then
say "would download $url"
say "would verify it against $sums_url"
say "would install $DIR/$BIN"
install_completions "" || true
ensure_on_path
return 0
fi
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t warmbly)
# The trap is set before the first write, so an interrupted install leaves
# nothing behind in /tmp.
trap 'rm -rf "$tmp"' EXIT INT TERM
step "Downloading $archive"
if ! fetch "$url" "$tmp/$archive"; then
die "could not download $url
If you pinned --version, check the tag exists: ${RELEASES}"
fi
# The checksum is the whole reason this is safer than a bare curl into tar:
# a truncated download and a tampered one look the same to tar.
if fetch "$sums_url" "$tmp/checksums.txt" 2>/dev/null; then
want=$(awk -v f="$archive" '$2 == f || $2 == "*"f { print $1 }' "$tmp/checksums.txt" | head -1)
got=$(sha256_of "$tmp/$archive")
if [ -z "$want" ]; then
warn "checksums.txt has no entry for $archive; continuing without verification"
elif [ -z "$got" ]; then
warn "no sha256 tool on this machine, so the download was not verified"
elif [ "$want" != "$got" ]; then
die "checksum mismatch for $archive.
expected $want
got $got
Nothing was installed. Try again, and if it happens twice report it: ${RELEASES}"
else
ok "checksum verified"
fi
else
warn "could not fetch checksums.txt; continuing without verification"
fi
step "Unpacking"
mkdir -p "$tmp/x"
tar -xzf "$tmp/$archive" -C "$tmp/x" || die "the archive could not be unpacked"
[ -f "$tmp/x/$BIN" ] || die "the archive did not contain $BIN"
mkdir -p "$DIR" 2>/dev/null || die "could not create $DIR.
Pick somewhere writable with --dir, for example: --dir \$HOME/bin"
# install(1) is not on every minimal image, so this is cp plus chmod, done
# to a temporary name and moved into place: replacing a running binary with
# a rename is atomic, overwriting one in place is not.
cp "$tmp/x/$BIN" "$DIR/.$BIN.new" || die "could not write to $DIR.
Pick somewhere writable with --dir, or re-run with sudo if $DIR is system-owned."
chmod 0755 "$DIR/.$BIN.new"
mv -f "$DIR/.$BIN.new" "$DIR/$BIN"
version_now=$("$DIR/$BIN" version 2>/dev/null | head -1 || echo "")
ok "installed ${version_now:-warmbly} to $DIR/$BIN"
install_completions "$tmp/x" || true
ensure_on_path
say ""
say "${C_BOLD}Next:${C_RESET} warmbly auth login"
say "${C_DIM}Docs: ${DOCS}${C_RESET}"
return 0
}
main() {
parse_args "$@"
setup_colors
require_downloader
if [ -n "$UNINSTALL" ]; then
do_uninstall
return 0
fi
do_install
}
main "$@"
+1
View File
@@ -0,0 +1 @@
30e5c97559826b3f10f70b4d5efd2b175120b9d98e0472069824d6aecfa924bf cli.sh