one URL, two answers: the page and the installer stop being the same file
dist/index.html was a polyglot — `#!/bin/sh`, a `<<\EOF` heredoc swallowing the whole document, the installer after the closing EOF. Both halves ran. The browser half was never honest, and could not be made honest. Anything before <!DOCTYPE html> that is not whitespace or a comment puts the document in quirks mode. Chromium, at 390 and 1280: compatMode BackCompat; document.head.children.length === 1, with all fifteen head elements — title, icons, viewport, description, every og: and twitter: tag — parsed into <body> where they do nothing; and "#!/bin/sh <<\EOF" as the first text node of the page, first string in body.innerText, pushing <main> down 24px. Proven by re-serving the identical live bytes with the 17-byte prefix stripped: BackCompat -> CSS1Compat, head.children 1 -> 15, at both widths. No arrangement of those bytes avoids it. Only whitespace and comments may precede a DOCTYPE; an HTML comment opens with `<!`, which a shell reads as a redirect from a file named `!--`, so a POSIX script cannot start there at all. The trick was unfixable, not unlucky. So the two resources become two files and `/` chooses between them by Accept, which is what HTTP has for this. worker.js is the whole rule: text/html gets dist/page.html, anything else — curl, wget, the /cli /mcp /dev shims that re-exec `curl -fsSL https://hanzo.sh` — gets dist/install.sh. `/` carries vary: accept and cache-control: no-store, because Cloudflare honours Vary only on Accept-Encoding and a shared cache holding one representation for the other kind of client breaks either the page or the install command. scripts/postbuild.js replaces the polyglot rewrite: it moves the document off index.html (Static Assets serve an exact match BEFORE the Worker runs, so a file at that name would hand curl the HTML) and writes /install from install.sh, so the alias is one file in git instead of 12,746 duplicated bytes. wrangler.toml: routes moves ABOVE [assets]. TOML puts every key after a table header inside that table, so it was parsing as assets.routes and wrangler dropped it with a warning — the custom domain survived only because it was attached out of band. html_handling and not_found_handling are "none": exact match or the Worker, nothing silently rewritten, a mistyped path still 404s. The deploy gate now checks both representations against the live URL, plus a 200 for every path the head declares — the check that would have caught og-image.svg. Verified against the real runtime before pushing: wrangler dev --local, curl gets install.sh byte-for-byte, a browser gets page.html with <!DOCTYPE html> at byte 0, `sh -n -c "$(curl -fsSL /)"` parses under dash and bash, and /index.html /nonexistent-xyz /some/deep/path still 404. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
name: deploy
|
||||
|
||||
# hanzo.sh is served by the Cloudflare assets Worker named `hanzo-sh`, and until
|
||||
# this file existed it was published BY HAND (`pnpm build && npx wrangler@3
|
||||
# deploy`). That is why a correct fix could sit merged on main for weeks while
|
||||
# `curl hanzo.sh | sh` kept handing out the old installer: the repo was right and
|
||||
# the bytes users got were not. A host that only updates when someone remembers
|
||||
# is a host that is stale by default.
|
||||
# hanzo.sh is served by the Cloudflare Worker named `hanzo-sh` over its own
|
||||
# static assets, and until this file existed it was published BY HAND (`pnpm
|
||||
# build && npx wrangler@3 deploy`). That is why a correct fix could sit merged on
|
||||
# main for weeks while `curl hanzo.sh | sh` kept handing out the old installer:
|
||||
# the repo was right and the bytes users got were not. A host that only updates
|
||||
# when someone remembers is a host that is stale by default.
|
||||
#
|
||||
# So: every push to main republishes, and the job REFUSES to pass unless the live
|
||||
# URL actually serves the new bytes. Deploying without checking is how you get a
|
||||
# green build over a stale site — the exact failure this replaces.
|
||||
# URL actually serves the new bytes — in BOTH representations, because `/` is two
|
||||
# resources: the installer to curl, the document to a browser.
|
||||
#
|
||||
# Runs on GitHub's runner: this org registers no self-hosted runner, and the only
|
||||
# copy of the Cloudflare credential lives in GitHub org secrets. It builds no
|
||||
# container image — the image path (Dockerfile -> hanzoai/static -> universe CR)
|
||||
# is a separate, unfinished migration documented in LLM.md.
|
||||
# copy of the Cloudflare credential lives in GitHub org secrets.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -47,19 +45,23 @@ jobs:
|
||||
|
||||
- run: pnpm build
|
||||
|
||||
# The same four asserts the Dockerfile makes. dist/index.html is a polyglot
|
||||
# — an HTML document AND the installer — and both halves have to survive.
|
||||
# dash as well as bash: the installer is POSIX and the page publishes
|
||||
# `| sh`, and a bash-only check is what let `set -o pipefail` ship in a file
|
||||
# whose first line says #!/bin/sh.
|
||||
- name: the polyglot is still both things
|
||||
# Two files, each whole. The document must open with <!DOCTYPE html> at
|
||||
# byte 0 or the browser parses it in quirks mode and drops every <head>
|
||||
# element into <body>; the installer must parse under dash AND bash,
|
||||
# because the page publishes `| sh` and readers type `| bash` anyway — a
|
||||
# bash-only check is what let `set -o pipefail` ship in a file whose first
|
||||
# line says #!/bin/sh. dist/index.html must NOT exist: an asset at that
|
||||
# name is served before the Worker runs, and curl would get the document.
|
||||
- name: the page is a document and the installer is a script
|
||||
run: |
|
||||
set -euo pipefail
|
||||
head -1 dist/index.html | grep -qx '#!/bin/sh'
|
||||
grep -qx '<!DOCTYPE html>' dist/index.html
|
||||
dash -n dist/index.html
|
||||
bash -n dist/index.html
|
||||
! grep -qiE 'astral\.sh|uv tool install' dist/index.html
|
||||
[ "$(head -c 15 dist/page.html)" = '<!DOCTYPE html>' ]
|
||||
[ ! -e dist/index.html ]
|
||||
head -1 dist/install.sh | grep -qx '#!/bin/sh'
|
||||
dash -n dist/install.sh
|
||||
bash -n dist/install.sh
|
||||
cmp dist/install dist/install.sh
|
||||
! grep -qiE 'astral\.sh|uv tool install' dist/install.sh
|
||||
|
||||
- name: publish
|
||||
env:
|
||||
@@ -67,9 +69,9 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: npx wrangler@3 deploy
|
||||
|
||||
# Cloudflare answers this host with `cf-cache-status: HIT`, so a fresh
|
||||
# deploy is not the same thing as a fresh response. Best-effort: the token
|
||||
# may not carry Cache Purge, and the check below is what actually decides.
|
||||
# `/` is now no-store, but the assets behind it are cached at the edge.
|
||||
# Best-effort: the token may not carry Cache Purge, and the check below is
|
||||
# what actually decides.
|
||||
- name: purge the edge
|
||||
continue-on-error: true
|
||||
env:
|
||||
@@ -86,24 +88,40 @@ jobs:
|
||||
"https://api.cloudflare.com/client/v4/zones/$zone/purge_cache" | head -c 300
|
||||
|
||||
# The only statement that matters: what does the live URL hand a stranger?
|
||||
# Twice — curl asks for anything, a browser asks for text/html.
|
||||
- name: the live bytes are the new bytes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
want=$(md5sum dist/index.html | cut -d' ' -f1)
|
||||
want_sh=$(md5sum dist/install.sh | cut -d' ' -f1)
|
||||
want_html=$(md5sum dist/page.html | cut -d' ' -f1)
|
||||
for i in $(seq 1 30); do
|
||||
got=$(curl -sS https://hanzo.sh | md5sum | cut -d' ' -f1)
|
||||
[ "$got" = "$want" ] && break
|
||||
got_sh=$(curl -sS https://hanzo.sh | md5sum | cut -d' ' -f1)
|
||||
got_html=$(curl -sS -H 'Accept: text/html' https://hanzo.sh | md5sum | cut -d' ' -f1)
|
||||
[ "$got_sh" = "$want_sh" ] && [ "$got_html" = "$want_html" ] && break
|
||||
sleep 10
|
||||
done
|
||||
echo "before : ${{ steps.before.outputs.md5 }}"
|
||||
echo "built : $want"
|
||||
echo "live : $got"
|
||||
[ "$got" = "$want" ] || {
|
||||
echo "::error::hanzo.sh is still serving different bytes than were just built"
|
||||
exit 1; }
|
||||
echo "before : ${{ steps.before.outputs.md5 }}"
|
||||
echo "installer : built $want_sh live $got_sh"
|
||||
echo "document : built $want_html live $got_html"
|
||||
[ "$got_sh" = "$want_sh" ] || {
|
||||
echo "::error::curl hanzo.sh is not the installer that was just built"; exit 1; }
|
||||
[ "$got_html" = "$want_html" ] || {
|
||||
echo "::error::a browser at hanzo.sh is not getting the page that was just built"; exit 1; }
|
||||
|
||||
# And it is still an installer, not just matching bytes.
|
||||
curl -sS https://hanzo.sh -o /tmp/live.sh
|
||||
dash -n /tmp/live.sh
|
||||
! grep -qiE 'astral\.sh|uv tool install' /tmp/live.sh
|
||||
|
||||
# Every path the page or the HTML head names. og:image used to point at
|
||||
# /og-image.svg, which was never in the repo — every social card was a
|
||||
# 404 and nothing said so.
|
||||
for p in /favicon.ico /favicon.svg /favicon-32x32.png /favicon-16x16.png \
|
||||
/apple-touch-icon.png /og-image.png /fonts/Geist-Variable.woff2 \
|
||||
/fonts/GeistMono-Variable.woff2 /install /install.sh /llms.txt \
|
||||
/cli /mcp /dev /full /bot /desktop /node; do
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' "https://hanzo.sh$p")
|
||||
[ "$code" = 200 ] || { echo "::error::hanzo.sh$p is $code"; exit 1; }
|
||||
done
|
||||
|
||||
echo "hanzo.sh serves the build from ${GITHUB_SHA:0:8}"
|
||||
|
||||
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# wrangler dev/deploy scratch
|
||||
.wrangler/
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/build-polyglot.js",
|
||||
"build": "vite build && node scripts/postbuild.js",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
|
||||
-328
@@ -1,328 +0,0 @@
|
||||
#!/bin/sh
|
||||
# hanzo installer
|
||||
#
|
||||
# curl -fsSL hanzo.sh | sh
|
||||
# curl -fsSL hanzo.sh | bash
|
||||
#
|
||||
# copyright (c) 2024-2026 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. The
|
||||
# polyglot the site serves already declares #!/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>.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# `dev` takes no second name on purpose. cli/install.sh defaults the alias to
|
||||
# `hanzo-node`, which the CLI already owns — giving it to `dev` too would point
|
||||
# the delegate name at a different program.
|
||||
TOOLS='hanzo:hanzoai/cli:hanzo:hanzo-node
|
||||
mcp:hanzoai/mcp:hanzo-mcp:mcp
|
||||
dev:hanzoai/dev:dev:'
|
||||
|
||||
# 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 not public yet
|
||||
desktop|`hanzo desktop` is in the CLI; the standalone app is not public yet
|
||||
bot|`hanzo bot` is in the CLI; the standalone node is not native yet
|
||||
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
|
||||
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-node, the same build)
|
||||
mcp the MCP server (also installed as hanzo-mcp, the same build)
|
||||
dev Hanzo Dev, the coding agent `hanzo code` runs by default
|
||||
|
||||
options:
|
||||
-d, --dir PATH install directory (default: ~/.local/bin)
|
||||
--version TAG pin the CLI release (default: latest)
|
||||
-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) HANZO_DIR="$2"; shift 2 ;;
|
||||
--version) HANZO_VERSION="$2"; export HANZO_VERSION; shift 2 ;;
|
||||
--quiet|-q) HANZO_QUIET=1; 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 ;;
|
||||
-*) warn "ignoring unknown option: $1"; shift ;;
|
||||
*) WANT="$WANT $1"; shift ;;
|
||||
esac
|
||||
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"|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>
|
||||
install_tool() {
|
||||
i_repo="$1"; i_bin="$2"; i_alias="$3"
|
||||
|
||||
# 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_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|not on PATH|export PATH' \
|
||||
| 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='?'
|
||||
|
||||
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
|
||||
ok "$i_bin $i_ver ${DM}(+ $i_alias, same build)${N}"
|
||||
else
|
||||
ok "$i_bin $i_ver"
|
||||
fi
|
||||
INSTALLED_N=$((INSTALLED_N + 1))
|
||||
}
|
||||
|
||||
show_unavailable() {
|
||||
[ "$HANZO_QUIET" = 1 ] && return 0
|
||||
printf '\n %snot included:%s\n' "$BD" "$N"
|
||||
unavailable_rows | while IFS='|' read -r u_name u_why; do
|
||||
printf ' %s○ %-8s %s%s\n' "$DM" "$u_name" "$u_why" "$N"
|
||||
done
|
||||
}
|
||||
|
||||
finish() {
|
||||
printf '\n'
|
||||
if [ "$INSTALLED_N" -eq 0 ]; then die "nothing was installed"; fi
|
||||
|
||||
# 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'
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf ' %sready%s %s→ %s%s\n\n' "$G" "$N" "$DM" "$HANZO_DIR" "$N"
|
||||
printf ' quick start:\n'
|
||||
printf ' %shanzo auth login%s # authenticate\n' "$C" "$N"
|
||||
printf ' %shanzo code%s # start a coding session\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 "$@"
|
||||
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"
|
||||
|
||||
mkdir -p "$HANZO_DIR"
|
||||
|
||||
selected=0
|
||||
for row in $TOOLS; do
|
||||
t_name=${row%%:*}; rest=${row#*:}
|
||||
t_repo=${rest%%:*}; rest=${rest#*:}
|
||||
t_bin=${rest%%:*}; t_alias=${rest#*:}
|
||||
if [ -n "$WANT" ] && ! wanted "$t_name" "$t_bin"; then continue; fi
|
||||
selected=1
|
||||
install_tool "$t_repo" "$t_bin" "$t_alias" || 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
|
||||
|
||||
if [ -z "$WANT" ]; then show_unavailable; 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 "$@"
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build script to create polyglot index.html
|
||||
* Makes the site work with: curl hanzo.sh | sh
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const distDir = join(rootDir, 'dist');
|
||||
const publicDir = join(rootDir, 'public');
|
||||
|
||||
// Read the built HTML
|
||||
const indexPath = join(distDir, 'index.html');
|
||||
let html = readFileSync(indexPath, 'utf-8');
|
||||
|
||||
// Read the install script (skip shebang)
|
||||
const installScript = readFileSync(join(publicDir, 'install.sh'), 'utf-8');
|
||||
const scriptContent = installScript.split('\n').slice(1).join('\n');
|
||||
|
||||
// Modify HTML closing tag to start HTML comment (hides shell script from browser)
|
||||
html = html.replace('</html>', '</html><!--');
|
||||
|
||||
// Create polyglot file
|
||||
const polyglot = `#!/bin/sh
|
||||
<<\\EOF
|
||||
${html}
|
||||
EOF
|
||||
|
||||
${scriptContent}
|
||||
`;
|
||||
|
||||
writeFileSync(indexPath, polyglot);
|
||||
console.log('✓ Created polyglot index.html');
|
||||
console.log(' curl hanzo.sh | sh → runs installer');
|
||||
console.log(' open hanzo.sh → shows landing page');
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Two things Vite cannot know about the shape hanzo.sh is served in.
|
||||
*
|
||||
* 1. The document must NOT be dist/index.html. Static Assets serve an exact
|
||||
* match before the Worker ever runs, so a file at that name would be handed
|
||||
* to `curl hanzo.sh | sh` as HTML. It moves to page.html; worker.js decides
|
||||
* which of page.html and install.sh `/` means.
|
||||
* 2. /install is a published alias of /install.sh (some deployed callers ask
|
||||
* for it). One file in git, two names in dist — copied here rather than
|
||||
* committed twice, and made from the same bytes so they cannot drift.
|
||||
*/
|
||||
|
||||
import { copyFileSync, renameSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const dist = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
|
||||
|
||||
renameSync(join(dist, 'index.html'), join(dist, 'page.html'))
|
||||
copyFileSync(join(dist, 'install.sh'), join(dist, 'install'))
|
||||
|
||||
console.log('dist/page.html the document, <!DOCTYPE html> at byte 0')
|
||||
console.log('dist/install.sh the installer, also served as /install')
|
||||
console.log('/ whichever of the two the Accept header asks for')
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* hanzo.sh answers one URL with two different things, and always has: the
|
||||
* landing page to a browser, the installer to `curl -fsSL hanzo.sh | sh` — the
|
||||
* command the page prints, llms.txt publishes, and every /cli /mcp /dev shim
|
||||
* re-executes.
|
||||
*
|
||||
* It used to do that by shipping a single file that was both at once: the built
|
||||
* HTML wrapped in a shell heredoc, `#!/bin/sh` on line 1. A browser cannot be
|
||||
* handed that honestly. Anything before <!DOCTYPE html> that is not whitespace
|
||||
* or a comment puts the document in quirks mode, and a shebang is neither — so
|
||||
* compatMode was BackCompat, all fifteen <head> elements (title, icons,
|
||||
* viewport, every og: and twitter: tag) were parsed into <body> where they do
|
||||
* nothing, and `#!/bin/sh <<\EOF` was the first text node on the page. No
|
||||
* arrangement of those bytes avoids it: the shell reads `<` as a redirect
|
||||
* operator, so a POSIX script cannot begin with `<!`, and only whitespace and
|
||||
* comments may precede a DOCTYPE. The trick was unfixable, not unlucky.
|
||||
*
|
||||
* So: one URL, two representations, chosen by Accept. That is what content
|
||||
* negotiation is for. curl asks for anything and gets the script; a browser
|
||||
* asks for text/html and gets a document whose first byte is `<`.
|
||||
*/
|
||||
|
||||
const PAGE = '/page.html'
|
||||
const INSTALLER = '/install.sh'
|
||||
|
||||
export default {
|
||||
async fetch (request, env) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname !== '/') return env.ASSETS.fetch(request)
|
||||
|
||||
const wantsPage = (request.headers.get('accept') || '').includes('text/html')
|
||||
const res = await env.ASSETS.fetch(new URL(wantsPage ? PAGE : INSTALLER, url))
|
||||
|
||||
// One URL, two answers: no shared cache may keep one and hand it to the
|
||||
// other kind of client. Cloudflare only honours Vary on Accept-Encoding, so
|
||||
// say it and also refuse storage outright.
|
||||
const headers = new Headers(res.headers)
|
||||
headers.set('vary', 'accept')
|
||||
headers.set('cache-control', 'no-store')
|
||||
return new Response(res.body, { status: res.status, headers })
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -1,16 +1,32 @@
|
||||
# hanzo.sh — assets-only Worker (CF Pages project limit reached; 7stars precedent)
|
||||
# hanzo.sh — a Worker over static assets (CF Pages project limit reached;
|
||||
# 7stars precedent).
|
||||
#
|
||||
# Deploying is .github/workflows/deploy.yml, which also re-fetches hanzo.sh and
|
||||
# fails unless the live bytes are the ones it just built. Do not publish this by
|
||||
# hand: hand-publishing is why the site served an installer months older than
|
||||
# main. `npx wrangler@3 deploy` still works locally for a break-glass, but then
|
||||
# main and the live host are two different states again — check afterwards.
|
||||
# The Worker exists for one rule: `/` is the installer to curl and the landing
|
||||
# page to a browser (worker.js). Every other path is a plain asset, matched
|
||||
# exactly — html_handling "none" so nothing is silently rewritten or redirected,
|
||||
# not_found_handling "none" so a mistyped path 404s instead of being handed an
|
||||
# installer under a name that promises something else.
|
||||
#
|
||||
# Deploying is .github/workflows/deploy.yml, which re-fetches hanzo.sh in both
|
||||
# representations and fails unless the live bytes are the ones it just built. Do
|
||||
# not publish this by hand: hand-publishing is why the site served an installer
|
||||
# months older than main. `npx wrangler@3 deploy` still works locally for a
|
||||
# break-glass, but then main and the live host are two different states again —
|
||||
# check afterwards.
|
||||
name = "hanzo-sh"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2026-07-01"
|
||||
|
||||
[assets]
|
||||
directory = "dist"
|
||||
|
||||
# Above [assets], not below it: TOML puts every key after a table header inside
|
||||
# that table, so this used to parse as assets.routes and wrangler dropped it
|
||||
# with a warning ("Unexpected fields found in assets field"). The custom domain
|
||||
# survived only because it was attached out of band.
|
||||
routes = [
|
||||
{ pattern = "hanzo.sh", custom_domain = true }
|
||||
]
|
||||
|
||||
[assets]
|
||||
directory = "dist"
|
||||
binding = "ASSETS"
|
||||
html_handling = "none"
|
||||
not_found_handling = "none"
|
||||
|
||||
Reference in New Issue
Block a user