mirror of
https://github.com/hanzoai/cli.git
synced 2026-10-30 15:00:27 +00:00
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.
282 lines
13 KiB
Bash
Executable File
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
|