Files
cli/install.sh
zeekay b31ae4c306 dev: Gen 7846eed; installs gen from v tags, checks what gh can check
hanzo-gen moves to 7846eed: every child it starts gets only the absolute
PATH entries, a signal during an agent's startup no longer orphans it,
and a run home is swept only when no session holds its lock.

install.sh installs a tool with a signer from vX.Y.Z tags only, the form
its release workflow runs on and its ruleset guards. It asks gh whether
it has `attestation verify --source-ref` before using it: Ubuntu's gh
2.45.0 has no attestation command, and the install died calling that a
tamper. Now a gh that cannot check says so, with its version and the
command that checks, and only a verification that ran and failed refuses
the install, with gh's reason.

link starts tmux, ttyd and ps by full path through gen's exe::command,
and upgrade starts npm, uv and the agents' own upgraders the same way.
2026-10-16 10:45:00 -07:00

282 lines
13 KiB
Bash
Executable File

#!/bin/sh
# Install a Hanzo native binary.
#
# curl -fsSL https://raw.githubusercontent.com/hanzoai/cli/main/install.sh | sh
#
# Downloads the prebuilt release asset for THIS machine, verifies its sha256, and
# puts it on PATH. Nothing is built, and no package manager is involved. It
# refuses loudly on a platform we do not publish, because a script that
# half-works is worse than one that says why it cannot.
#
# This is the ONE implementation of "fetch a Hanzo binary". It defaults to the
# `hanzo` CLI, and hanzo.sh drives it once per tool by overriding three
# variables rather than carrying a second copy of platform detection, asset
# naming and checksum verification:
#
# HANZO_INSTALL_REPO owning repo (default hanzoai/cli)
# HANZO_INSTALL_BIN binary + asset prefix (default hanzo)
# HANZO_INSTALL_ALIAS second name, same build (default hanzo-node; "" = none)
# HANZO_INSTALL_PREFIX install dir (default ~/.local/bin)
# HANZO_VERSION pin a tag (default: highest stable semver)
# HANZO_INSTALL_SIGNER the workflow that must have built the asset, for a tool
# whose releases are attested (e.g. .github/workflows/release.yml);
# such a tool is installed from vX.Y.Z tags only
#
# The convention every published Hanzo binary follows, and the only thing this
# needs to know: the asset is <BIN>-<os>-<arch>.tar.gz, it is accompanied by
# <asset>.sha256, and it unpacks to a single file named <BIN>.
set -eu
REPO="${HANZO_INSTALL_REPO:-hanzoai/cli}"
BIN="${HANZO_INSTALL_BIN:-hanzo}"
# The second name this same build installs under: what cloud's control binary
# delegates to. One build, two names — never two versions.
DELEGATE="${HANZO_INSTALL_ALIAS-hanzo-node}"
PREFIX="${HANZO_INSTALL_PREFIX:-$HOME/.local/bin}"
SIGNER="${HANZO_INSTALL_SIGNER:-}"
die() { printf '\n%s: %s\n' "$BIN" "$1" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "need $1 on PATH"; }
need curl
need tar
need awk
# A private repo answers 404 to an anonymous asset fetch, so carry a token when
# one is available. Public installs need none.
TOKEN="${HANZO_INSTALL_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
if [ -z "$TOKEN" ] && command -v gh >/dev/null 2>&1; then
TOKEN="$(gh auth token 2>/dev/null || true)"
fi
# Never interpolate the token into an argument list: unquoted substitution
# word-splits it into its own argv entry, and curl echoes argv on failure — which
# prints the token. Branch, and keep the header a single quoted argument.
# Whether to draw curl's own progress bar, decided once.
#
# A binary is tens of megabytes and `-s` draws nothing while it comes down, so
# a slow connection is indistinguishable from a hang — which is how somebody
# ends up pressing ctrl-c halfway through an install that was working.
#
# The bar goes to stderr. `curl hanzo.sh | sh` consumes stdout and leaves
# stderr on the terminal, so it is visible in exactly the case this is for.
# Where there is no terminal — CI, a log file, a pipe to tee — a bar is a few
# thousand carriage returns in the transcript, so it stays silent there and
# `-S` keeps errors reported either way.
if [ -t 2 ]; then DL='-#'; else DL='-sS'; fi
get() { # get <url> <dest>
if [ -n "$TOKEN" ]; then
curl -fL $DL -H "Authorization: Bearer $TOKEN" "$1" -o "$2"
else
curl -fL $DL "$1" -o "$2"
fi
}
get_stdout() { # get_stdout <url>
if [ -n "$TOKEN" ]; then
curl -fsSL -H "Authorization: Bearer $TOKEN" "$1"
else
curl -fsSL "$1"
fi
}
# Published targets: linux-{amd64,arm64}, darwin-{amd64,arm64}, windows-amd64.
os="$(uname -s)"
arch="$(uname -m)"
ext=""
case "$os" in
Linux) os=linux ;;
Darwin) os=darwin ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT) os=windows; ext=".exe" ;; # git-bash / msys2
*) die "unsupported OS '$os'." ;;
esac
case "$arch" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) die "unsupported architecture '$arch'." ;;
esac
target="${os}-${arch}"
TAG="${HANZO_VERSION:-}"
# An attested tool ships from `vX.Y.Z` tags only: the form its release workflow
# runs on and its tag ruleset guards. Any other tag in its repository is one no
# release of it made, whatever assets it carries.
form='(rust-)?v'
[ -z "$SIGNER" ] || form='v'
if [ -n "$TAG" ] && [ -n "$SIGNER" ]; then
printf '%s' "$TAG" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
|| die "$TAG is not a release tag of $REPO (vX.Y.Z)"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
asset="${BIN}-${target}.tar.gz"
# The tags come from git's ref advertisement, not the REST API. github.com serves
# it without the API's limit of 60 anonymous calls an hour per address, and
# listing releases through the API cost one call per 100 releases per tool: 8 of
# the 60 for one `curl hanzo.sh | sh`. Behind a shared NAT or on a CI runner the
# limit was spent before the installer ran, and nothing installed.
#
# /latest is not used either: it follows release chronology, which can select an
# older maintenance release over a higher version.
tags() {
url="https://github.com/$REPO.git/info/refs?service=git-upload-pack"
if [ -n "$TOKEN" ]; then
curl -fsS -H "Authorization: Basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" "$url"
else
curl -fsS "$url"
fi
}
# has <tag>: does the release at <tag> carry THIS machine's archive and its
# checksum? A release is not one object per product: MCP also publishes
# independent JavaScript versions in the same repository, and a release whose
# build for one target failed still went out with the others (hanzoai/cli
# v8.5.158 has no linux-amd64, hanzoai/mcp rust-v1.1.23 no darwin). Choosing it
# would end the install on a 404 when the release before it has this machine's
# build. A draft's assets answer 404 until it is published, so a draft is never
# chosen either.
has() {
if [ -z "$TOKEN" ]; then
curl -fs -I -o /dev/null "https://github.com/$REPO/releases/download/$1/$asset.sha256" &&
curl -fs -I -o /dev/null "https://github.com/$REPO/releases/download/$1/$asset"
return
fi
# A private release's download URL answers 404 even with a token; the API
# names its assets.
get_stdout "https://api.github.com/repos/$REPO/releases/tags/$1" > "$tmp/release.json" 2>/dev/null &&
grep -qF "\"$asset\"" "$tmp/release.json" &&
grep -qF "\"$asset.sha256\"" "$tmp/release.json"
}
if [ -z "$TAG" ]; then
tags > "$tmp/refs" || die "could not list the tags of $REPO"
# One ref per pkt-line, "<len><sha> refs/tags/<tag>", with NULs around the
# capability list; a peeled "<tag>^{}" line fails the pattern.
tr '\000' '\n' < "$tmp/refs" | awk -v form="$form" '
$2 ~ ("^refs/tags/" form "(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$") {
tag = substr($2, 11); version = tag; sub("^" form, "", version)
print version "\t" tag
}' | sort -t . -k1,1nr -k2,2nr -k3,3nr | awk '{ print $2 }' > "$tmp/tags"
while read -r candidate; do
if has "$candidate"; then TAG="$candidate"; break; fi
done < "$tmp/tags"
[ -n "$TAG" ] || die "no stable release of $REPO has a build for $target.
If $REPO is private, set GH_TOKEN (or run \`gh auth login\`); or pin HANZO_VERSION=vX.Y.Z."
fi
base="https://github.com/$REPO/releases/download/$TAG"
# A private release's browser download URL is not fetchable with a token; assets
# must come from the API by id, with an octet-stream Accept.
fetch() { # fetch <asset-name> <dest>
if [ -z "$TOKEN" ]; then
get "$base/$1" "$2"
return
fi
# A private release's browser URL is not token-fetchable; assets come from the
# API by id, with an octet-stream Accept.
# Pull the asset id out of the release JSON. GitHub pretty-prints, so collapse
# the newlines FIRST — otherwise each asset object spans many lines and the
# "id" never lands on the same record as the "name" we matched.
id="$(get_stdout "https://api.github.com/repos/$REPO/releases/tags/$TAG" \
| tr -d '\n' | tr '{' '\n' \
| grep -F "\"$1\"" \
| sed -n 's/.*"id": *\([0-9][0-9]*\).*/\1/p' | head -1)"
[ -n "$id" ] || return 1
curl -fL $DL -H "Authorization: Bearer $TOKEN" -H "Accept: application/octet-stream" \
"https://api.github.com/repos/$REPO/releases/assets/$id" -o "$2"
}
printf '%s: %s %s\n' "$BIN" "$TAG" "$target"
fetch "$asset" "$tmp/$asset" \
|| die "no published build for $target at $TAG."
fetch "$asset.sha256" "$tmp/$asset.sha256" \
|| die "release $TAG has no checksum for $target — refusing to install unverified"
# Verify BEFORE unpacking: an unverified binary is not installed, ever.
( cd "$tmp" && \
if command -v sha256sum >/dev/null 2>&1; then sha256sum -c "$asset.sha256";
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -c "$asset.sha256";
else die "need sha256sum or shasum to verify the download"; fi ) >/dev/null \
|| die "checksum MISMATCH for $asset — refusing to install"
# PROVENANCE, for a tool whose releases are attested. The checksum above came
# from the same release as the archive, so it proves the bytes arrived whole and
# nothing about where they came from. The attestation does: Sigstore's record
# that THIS repository's $SIGNER, run for THIS tag, built exactly these bytes.
# gh carries the verification, so it is checked when a signed-in gh that can
# verify an attestation's tag is here, and a verification that ran and failed
# refuses the install, with gh's reason. Otherwise it is not checked, and the
# line says why and how to check it — never a silent pass.
if [ -n "$SIGNER" ]; then
if ! command -v gh >/dev/null 2>&1; then
unchecked="needs gh"
elif ! gh auth status >/dev/null 2>&1; then
unchecked="needs a signed-in gh"
elif ! gh attestation verify --help 2>/dev/null | grep -q -- '--source-ref'; then
unchecked="$(gh --version 2>/dev/null | head -1 || true) cannot check an attestation's tag"
else
unchecked=""
fi
if [ -z "$unchecked" ]; then
gh attestation verify "$tmp/$asset" --repo "$REPO" --signer-workflow "$REPO/$SIGNER" \
--source-ref "refs/tags/$TAG" > "$tmp/provenance" 2>&1 \
|| { sed 's/^/ /' "$tmp/provenance" >&2
die "$asset has no attestation that $REPO/$SIGNER built it at $TAG — refusing to install"; }
printf '%s: provenance: %s built %s at %s\n' "$BIN" "$REPO/$SIGNER" "$asset" "$TAG"
else
printf '%s: provenance not checked (%s): gh attestation verify %s --repo %s --signer-workflow %s/%s --source-ref refs/tags/%s\n' \
"$BIN" "$unchecked" "$asset" "$REPO" "$REPO" "$SIGNER" "$TAG"
fi
fi
tar -xzf "$tmp/$asset" -C "$tmp"
[ -f "$tmp/$BIN$ext" ] || die "archive did not contain '$BIN$ext'"
mkdir -p "$PREFIX"
mv "$tmp/$BIN$ext" "$PREFIX/$BIN$ext"
chmod 755 "$PREFIX/$BIN$ext"
printf '%s: installed %s\n' "$BIN" "$PREFIX/$BIN$ext"
# The SAME build under a second name. cloud's control binary hands every verb it
# does not own to `hanzo-node` (it resolves that name before `hanzo`), so the two
# names must never be two versions: install one and not the other, or upgrade one
# and not the other, and a user types `hanzo`, gets delegated, and runs an old
# build with no version anywhere on screen. That is not theoretical — a stale
# twin served ~150 commands that no longer existed, silently.
#
# A symlink makes the skew impossible rather than merely unlikely; where links
# are not available (Windows) a copy still matches by version. A caller that
# wants no second name passes HANZO_INSTALL_ALIAS=''.
if [ -n "$DELEGATE" ]; then
if ln -sf "$BIN$ext" "$PREFIX/$DELEGATE$ext" 2>/dev/null; then
:
else
cp -f "$PREFIX/$BIN$ext" "$PREFIX/$DELEGATE$ext" && chmod 755 "$PREFIX/$DELEGATE$ext"
fi
[ -e "$PREFIX/$DELEGATE$ext" ] \
|| die "could not install the second name $PREFIX/$DELEGATE$ext — a half-install is not an install"
printf '%s: installed %s (the same build, second name)\n' "$BIN" "$PREFIX/$DELEGATE$ext"
fi
# Whatever PATH says, name the copy that would ACTUALLY run. An earlier entry
# wins, and that precedence is exactly how a stale install hides: the user
# upgrades here and keeps running something else, and re-running the installer
# never fixes it because it keeps writing to a directory that never wins.
found="$(command -v "$BIN" 2>/dev/null || true)"
if [ -n "$found" ] && [ "$found" != "$PREFIX/$BIN$ext" ]; then
printf '%s: WARNING %s comes first on PATH and will run instead of the\n' "$BIN" "$found"
printf ' build just installed at %s. Remove it, or put %s first.\n' "$PREFIX/$BIN$ext" "$PREFIX"
fi
case ":$PATH:" in
*":$PREFIX:"*) ;;
*) printf '%s: %s is not on PATH — add it:\n export PATH="%s:$PATH"\n' "$BIN" "$PREFIX" "$PREFIX" ;;
esac
# `hanzo auth login`, not `hanzo login`: the top-level alias arrived in 8.5.157,
# and a build before it reads an unrecognised first word as a task for the coding
# agent, so it would start a session about the word "login" instead of signing
# anyone in. HANZO_VERSION can pin such a build.
if [ "$BIN" = hanzo ]; then printf 'hanzo: next → hanzo auth login\n'; fi