Files
hanzo.sh/public/install.sh
zeekay 31b75a7948 fix(installer): report partial installs and provenance clearly
Install prefixed native tool aliases, add strict verification, consolidate PATH guidance, and remove stale default availability notices. Explicit Node and Bot requests explain that native release assets are unavailable.

Testing: installer coordinator fixtures passed under sh and bash; pnpm build passed; generated installer parses in both shells.

Risk: strict mode requires the updated shared CLI installer and refuses older implementations. Existing short names remain supported.

How to revert: revert this commit and redeploy the site.
2026-10-21 14:28:40 -07:00

380 lines
15 KiB
Bash

#!/bin/sh
# hanzo installer
#
# curl -fsSL hanzo.sh | sh
# curl -fsSL hanzo.sh | bash
#
# copyright (c) Hanzo AI, Inc.
# https://hanzo.ai
#
# Downloads one prebuilt native binary per tool, verifies its sha256, and puts it
# on PATH. That is the whole design. Nothing is built here, no package manager is
# involved, and there is no runtime to install first.
#
# POSIX sh on purpose. The site published `| bash` and llms.txt published `| sh`,
# and the script was bash-only — so every reader who copied the `sh` form (every
# agent reading llms.txt) got `set: Illegal option -o pipefail` from dash and
# nothing installed. Rather than pick a winner and leave the other broken, this
# runs under both: no arrays, no [[ ]], no <<<, no echo -e, no pipefail. Line 1
# of this file is what the site hands curl, and it says #!/bin/sh, so this also
# makes that shebang honest.
#
# It does NOT re-implement downloading. hanzoai/cli/install.sh is the one
# implementation of "fetch a Hanzo binary" — platform detection, asset naming,
# checksum verification and the second-name symlink all live there, once. This
# fetches it and drives it once per tool. Two copies of platform detection would
# be the same class of bug as two copies of a route table.
#
# Every tool below is a published, public, checksummed native binary. A tool that
# is not one is NOT installed by some other mechanism to pad the list — it is
# named, with the reason. An installer that quietly reaches for a package manager
# to look complete is the defect this file exists to remove.
set -eu
HANZO_DIR="${HANZO_INSTALL_DIR:-$HOME/.local/bin}"
HANZO_QUIET="${HANZO_QUIET:-0}"
CLI_REPO="${HANZO_CLI_REPO:-hanzoai/cli}"
# The complete set of Hanzo tools that ship as a public, checksummed, native
# binary today, as <name>:<repo>:<binary>:<second-name>[:<signer>].
#
# <signer> is the workflow that builds and attests a tool's releases. With one,
# cli/install.sh checks each download's attestation against that workflow and
# the release's tag (with a signed-in gh), and refuses one that does not match.
#
# The binary is also the asset prefix and the name inside the tarball — the
# convention hanzoai/cli/install.sh relies on. Adding a tool is one line, the day
# its repo is public and publishes <binary>-<os>-<arch>.tar.gz.sha256.
#
# Prefixed aliases give each product a distinct command. hanzo-cli is the
# control plane delegate; hanzo-node is reserved for the network node.
TOOLS='hanzo:hanzoai/cli:hanzo:hanzo-cli
mcp:hanzoai/mcp:hanzo-mcp:mcp
dev:hanzoai/dev:dev:hanzo-dev
gen:hanzoai/gen:gen:hanzo-gen:.github/workflows/release.yml
zt:hanzozt/zt:zt:hanzo-zet'
# The installable names, read off TOOLS so no message can name a different set
# than the loop installs.
tool_names() {
printf '%s\n' "$TOOLS" | while IFS= read -r n_row; do
printf ' %s' "${n_row%%:*}"
done
}
# Named, not silently skipped, and never substituted with something else.
unavailable_rows() {
cat <<'ROWS'
node|source is public at github.com/hanzoai/node; no prebuilt release is published yet
desktop|no standalone desktop release is published yet
bot|source is public at github.com/hanzoai/bot; its current release has no prebuilt binaries
ROWS
}
if [ -t 1 ] && [ "${TERM:-}" != dumb ]; then
R=$(printf '\033[0;31m'); G=$(printf '\033[0;32m'); Y=$(printf '\033[0;33m')
C=$(printf '\033[0;36m'); N=$(printf '\033[0m'); BD=$(printf '\033[1m')
DM=$(printf '\033[2m')
else
R=''; G=''; Y=''; C=''; N=''; BD=''; DM=''
fi
INSTALLED_N=0
INSTALLED_LIST=''
FAILED_N=0
FAILED_LIST=''
log() { [ "$HANZO_QUIET" = 1 ] || printf ' %s\n' "$1"; }
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$1"; }
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$1"; }
fail() { printf ' %s✗%s %s\n' "$R" "$N" "$1"; }
die() { fail "$1"; exit 1; }
banner() {
[ "$HANZO_QUIET" = 1 ] && return 0
printf '%s' "$DM"
cat <<'ART'
__
/ /_ ____ _____ ____ ____
/ __ \/ __ `/ __ \/_ / / __ \
/ / / / /_/ / / / / / /_/ /_/ /
/_/ /_/\__,_/_/ /_/ /___/\____/
ART
printf '%s\n' "$N"
}
usage() {
cat <<'EOF'
hanzo installer
usage: curl -fsSL hanzo.sh | sh
curl -fsSL hanzo.sh | sh -s -- [options] [tool...]
Downloads one prebuilt native binary per tool and verifies its checksum.
With no tool named, installs every tool that ships as a public native binary.
tools:
hanzo the Hanzo CLI (also installed as hanzo-cli, the same build)
mcp the MCP server (also installed as hanzo-mcp, the same build)
dev Hanzo Dev (dev and hanzo-dev)
gen Hanzo Gen (gen and hanzo-gen)
zt ZT zero-trust networking (zt and hanzo-zet)
options:
-d, --dir PATH install directory (default: ~/.local/bin)
--version TAG pin the CLI release (default: latest)
--strict require release provenance verification for every tool
-q, --quiet less output
-h, --help show this help
shortcuts:
curl -fsSL hanzo.sh | sh # every installable tool
curl -fsSL hanzo.sh/cli | sh # the CLI only
curl -fsSL hanzo.sh/mcp | sh # the MCP server only
Re-run any time to upgrade; installing is just downloading the current build.
EOF
exit 0
}
WANT=''
parse_args() {
while [ $# -gt 0 ]; do
case $1 in
--dir|-d|--version|--bundle|-b)
[ $# -ge 2 ] && [ -n "$2" ] || die "$1 needs a value (see --help)" ;;
esac
case $1 in
--dir|-d) HANZO_DIR="$2"; shift 2 ;;
--version) HANZO_VERSION="$2"; export HANZO_VERSION; shift 2 ;;
--quiet|-q) HANZO_QUIET=1; shift ;;
--strict) HANZO_INSTALL_STRICT=1; export HANZO_INSTALL_STRICT; shift ;;
--help|-h) usage ;;
# Retired flags. Installing IS upgrading — there is one path and it
# always fetches the current build — so these mean nothing now.
# Accept and ignore rather than failing an install someone scripted.
--upgrade|-u|--force|-f) shift ;;
--bundle|-b) WANT="$WANT $2"; shift 2 ;;
-*) die "unknown option: $1 (see --help)" ;;
*) WANT="$WANT $1"; shift ;;
esac
done
}
validate_wanted() {
for v_want in $WANT; do
case "$v_want" in default|full|all) continue ;; esac
v_found=0
for v_row in $TOOLS; do
v_name=${v_row%%:*}; v_rest=${v_row#*:}; v_rest=${v_rest#*:}
v_bin=${v_rest%%:*}; v_rest=${v_rest#*:}; v_alias=${v_rest%%:*}
case "$v_want" in "$v_name"|"$v_bin"|"$v_alias") v_found=1 ;; esac
done
[ "$v_found" = 0 ] || continue
v_short=${v_want#hanzo-}
v_why=$(unavailable_rows | while IFS='|' read -r v_name v_reason; do
if [ "$v_name" = "$v_short" ]; then printf '%s' "$v_reason"; fi
done)
[ -z "$v_why" ] || die "$v_want — $v_why"
die "no such tool: $v_want — installable today:$(tool_names)"
done
}
# wanted <name> <binary> — did the caller ask for this tool?
# `default`, `full` and `all` named the old bundles. They meant "everything",
# which is what naming nothing means, so they map to that rather than failing an
# install someone already scripted.
wanted() {
for w in $WANT; do
case $w in
"$1"|"$2"|"${3:-$1}"|default|full|all) return 0 ;;
esac
done
return 1
}
# hanzoai/cli/install.sh, fetched once and reused for every tool.
#
# Prefer the API with an explicit raw Accept: it serves the CURRENT file, while
# raw.githubusercontent sits behind a CDN that hands back a copy from some
# minutes ago. Serving a stale script is the exact bug this rewrite removes, so
# do not risk it — but fall back to raw when the API is unreachable or has
# rate-limited an anonymous caller, because a rate limit must not stop an install.
ONE_INSTALLER=''
fetch_one_installer() {
api="https://api.github.com/repos/${CLI_REPO}/contents/install.sh?ref=main"
token="${HANZO_INSTALL_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
# Never interpolate a token into an unquoted argument list; branch instead.
if [ -n "$token" ]; then
curl -fsSL -H "Accept: application/vnd.github.raw" \
-H "Authorization: Bearer $token" "$api" -o "$1" 2>/dev/null && return 0
else
curl -fsSL -H "Accept: application/vnd.github.raw" "$api" -o "$1" 2>/dev/null && return 0
fi
curl -fsSL "https://raw.githubusercontent.com/${CLI_REPO}/main/install.sh" -o "$1" 2>/dev/null
}
# install <repo> <binary> <second-name> <signer>
install_tool() {
i_repo="$1"; i_bin="$2"; i_alias="$3"; i_signer="$4"
# HANZO_VERSION names a CLI release. Passing it to anything else would pin a
# tag that repo has never published, so it reaches exactly one tool.
i_pin=''
if [ "$i_repo" = "$CLI_REPO" ]; then i_pin="${HANZO_VERSION:-}"; fi
if i_out=$(HANZO_INSTALL_PREFIX="$HANZO_DIR" \
HANZO_INSTALL_REPO="$i_repo" \
HANZO_INSTALL_BIN="$i_bin" \
HANZO_INSTALL_ALIAS="$i_alias" \
HANZO_INSTALL_SIGNER="$i_signer" \
HANZO_VERSION="$i_pin" \
sh "$ONE_INSTALLER" 2>&1); then
# Surface the installer's own warnings (PATH shadowing, PATH missing).
# They are the difference between an install that worked and one the user
# will never actually run — so carry the CONTINUATION lines too. The
# shadow warning's second line is the half that says what to do about it,
# and a filter that keeps only the alarm and drops the remedy is worse
# than not warning at all.
printf '%s\n' "$i_out" \
| grep -E 'WARNING|Remove it, or put|provenance' \
| sed 's/^/ /' || true
else
printf '%s\n' "$i_out" | sed 's/^/ /'
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_bin"
fail "$i_bin"; return 1
fi
# Trust the artifact, not the log line: ask the binary that is now on disk.
if [ ! -x "$HANZO_DIR/$i_bin" ]; then
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_bin"
fail "$i_bin (nothing at $HANZO_DIR/$i_bin)"; return 1
fi
# The first token that looks like a version, not the last token on the line:
# `--version` output is not one shape across tools (`hanzo 1.9.18` ends on
# the version, `hanzo dev 0.6.91 (sha) 2026-04-01` ends on a date), and
# printing a build date where a version belongs is its own small lie.
i_ver=$("$HANZO_DIR/$i_bin" --version 2>/dev/null | head -1 \
| tr ' ' '\n' | grep -m1 -E '^v?[0-9]+\.[0-9]+' | sed 's/^v//')
[ -n "${i_ver:-}" ] || i_ver='?'
i_unverified=0
if printf '%s\n' "$i_out" | grep -q 'provenance not checked'; then i_unverified=1; fi
if [ -n "$i_alias" ]; then
# Both names or it is not a finished install. A missing second name falls
# through to whatever older copy is already on the box, silently.
if [ ! -e "$HANZO_DIR/$i_alias" ]; then
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_alias"
fail "$i_alias was not installed alongside $i_bin"; return 1
fi
if [ "$i_unverified" = 1 ]; then
warn "$i_bin $i_ver — installed; provenance unverified (+ $i_alias, same build)"
else
ok "$i_bin $i_ver ${DM}(+ $i_alias, same build)${N}"
fi
else
if [ "$i_unverified" = 1 ]; then
warn "$i_bin $i_ver — installed; provenance unverified"
else
ok "$i_bin $i_ver"
fi
fi
INSTALLED_N=$((INSTALLED_N + 1))
INSTALLED_LIST="$INSTALLED_LIST $i_bin"
}
finish() {
printf '\n'
if [ "$INSTALLED_N" -eq 0 ]; then die "nothing was installed"; fi
printf ' Installed and available in %s:%s\n' "$HANZO_DIR" "$INSTALLED_LIST"
case ":$PATH:" in
*":$HANZO_DIR:"*) ;;
*) printf '\n PATH setup needed for this shell (installation succeeded):\n'
printf ' export PATH="%s:$PATH"\n' "$HANZO_DIR" ;;
esac
# Never say "ready" over a failure. A partial install that reads as success
# is how someone spends an afternoon on a tool that was never there.
if [ "$FAILED_N" -gt 0 ]; then
printf ' %s%sincomplete%s — did not install:%s\n' "$R" "$BD" "$N" "$N"
for f in $FAILED_LIST; do printf ' %s✗%s %s\n' "$R" "$N" "$f"; done
printf '\n The tools listed as installed above still work.\n'
printf ' Re-run the same install command to retry the failed downloads.\n'
printf '\n'
return 0
fi
printf ' %sready%s %s→ %s%s\n\n' "$G" "$N" "$DM" "$HANZO_DIR" "$N"
printf ' quick start:\n'
printf ' %shanzo%s # sign in, pick your coding agent, launch it\n' "$C" "$N"
printf ' %shanzo up%s # run the cloud on this machine\n' "$C" "$N"
printf ' %shanzo --help%s # every command\n' "$C" "$N"
if [ -x "$HANZO_DIR/hanzo-mcp" ]; then
printf ' %shanzo-mcp --help%s # the MCP server\n' "$C" "$N"
fi
printf '\n docs: https://docs.hanzo.ai\n\n'
}
main() {
parse_args "$@"
validate_wanted
banner
command -v curl >/dev/null 2>&1 || die "need curl on PATH"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
ONE_INSTALLER="$tmp/install.sh"
fetch_one_installer "$ONE_INSTALLER" \
|| die "could not fetch the installer from ${CLI_REPO}"
[ -s "$ONE_INSTALLER" ] || die "the installer fetched from ${CLI_REPO} is empty"
if [ "${HANZO_INSTALL_STRICT:-0}" = 1 ] && ! grep -q 'HANZO_INSTALL_STRICT' "$ONE_INSTALLER"; then
die "the downloaded installer does not support strict verification; retry after the installer update is available. Nothing was installed."
fi
mkdir -p "$HANZO_DIR"
selected=0
for row in $TOOLS; do
t_name=${row%%:*}; rest=${row#*:}
t_repo=${rest%%:*}; rest=${rest#*:}
t_bin=${rest%%:*}; rest=${rest#*:}
t_alias=${rest%%:*}
case $rest in *:*) t_signer=${rest#*:} ;; *) t_signer='' ;; esac
if [ -n "$WANT" ] && ! wanted "$t_name" "$t_bin" "$t_alias"; then continue; fi
selected=1
install_tool "$t_repo" "$t_bin" "$t_alias" "$t_signer" || true
done
if [ "$selected" -eq 0 ]; then
# Asked for something real that we publish no binary for. Answer with the
# reason from the one place that holds it, rather than a "no such tool"
# that reads like a typo. Non-zero: they asked for an install and did not
# get one.
for w in $WANT; do
why=$(unavailable_rows | while IFS='|' read -r u_name u_why; do
if [ "$u_name" = "$w" ]; then printf '%s' "$u_why"; fi
done)
if [ -n "$why" ]; then
fail "$w — $why"
printf ' %sinstallable today:%s%s\n' "$DM" "$(tool_names)" "$N"
exit 1
fi
done
die "no such tool:$WANT — installable today:$(tool_names)"
fi
finish
# A failed download or a missing second name is a failed install, and the
# exit code has to say so — a half-install discovered days later is worse
# than one that stopped here.
if [ "$FAILED_N" -gt 0 ]; then exit 1; fi
exit 0
}
main "$@"