diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c00c8f9..fb9072fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8a5e405..38692006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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, '-') }} diff --git a/.gitignore b/.gitignore index db16a706..47c8ce27 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 7591ea53..4481f169 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/deploy/docker/backend.Dockerfile b/deploy/docker/backend.Dockerfile index edc4bbc5..cec8561c 100644 --- a/deploy/docker/backend.Dockerfile +++ b/deploy/docker/backend.Dockerfile @@ -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 diff --git a/deploy/docker/cli.Dockerfile b/deploy/docker/cli.Dockerfile new file mode 100644 index 00000000..c2b9c3a7 --- /dev/null +++ b/deploy/docker/cli.Dockerfile @@ -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"] diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh new file mode 100755 index 00000000..3dc59217 --- /dev/null +++ b/scripts/build-cli.sh @@ -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__.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" < "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" <&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' diff --git a/site/public/cli.ps1 b/site/public/cli.ps1 new file mode 100644 index 00000000..82d948ed --- /dev/null +++ b/site/public/cli.ps1 @@ -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 } diff --git a/site/public/cli.sh b/site/public/cli.sh new file mode 100644 index 00000000..e2c3fc7e --- /dev/null +++ b/site/public/cli.sh @@ -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__.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 <&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 "$@" diff --git a/site/public/cli.sh.sha256 b/site/public/cli.sh.sha256 new file mode 100644 index 00000000..870df4e9 --- /dev/null +++ b/site/public/cli.sh.sha256 @@ -0,0 +1 @@ +30e5c97559826b3f10f70b4d5efd2b175120b9d98e0472069824d6aecfa924bf cli.sh