Files
hanzo.sh-native/LLM.md
zeekayandHanzo Dev 956531adc7 hanzo.sh to the estate standard: gui 8.0.0 graph, ui 8.0.38, TS 7.0.2, static gui.css
- @hanzo/gui + whole @hanzogui/* graph 7.3.0 -> 8.0.0, @hanzo/ui -> 8.0.38,
  @hanzo/design -> 0.3.3; [email protected].5 declared directly
  ([email protected] imports it but no longer declares the peer).
- Icons from the package root, not /icons/* subpaths: 8.0.0's per-icon exports
  entries carry no types condition, so subpaths typecheck as any. Root export
  is typed and sideEffects:false tree-shakes — JS bundle 647 kB -> 531 kB.
- gui base sheet as a REAL cacheable stylesheet: scripts/gen-gui-css.mjs
  (first step of pnpm build) writes config.getCSS() to src/gui.css, main.tsx
  imports it, GuiProvider renders disableInjectCSS. ~43 kB gzip, fingerprinted.
- TypeScript 7.0.2 for tsc; .pnpmfile.cjs gives @typescript-eslint/* their own
  [email protected] (side-by-side per typescript-eslint#10940) so lint runs.
- next-theme shim STAYS: 8.0.38 lazily code-splits ThemeToggleNext, but Rollup
  still follows the dynamic import and the optional peer breaks the build
  (measured). SiteNav still authors $sm as phone, so the sm override stays too.
- Measured: typecheck 0, lint 0, build 0, polyglot bash -n OK + --help prints
  usage, page verified at 1280px and 390px.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-31 14:39:37 -07:00

8.6 KiB

hanzo.sh

The install surface: one page that is also one installer. curl hanzo.sh | bash installs the Hanzo toolkit; opening hanzo.sh in a browser renders the landing page. Both are the same file.

The polyglot

pnpm build is vite build && node scripts/build-polyglot.js. The second step rewrites dist/index.html into a file that is simultaneously:

  • a shell script — line 1 is #!/bin/sh, line 2 opens a <<\EOF heredoc that swallows the whole HTML document, and the installer (public/install.sh, minus its own shebang) follows the closing EOF;
  • an HTML document — the heredoc content is the real page, and </html> is rewritten to </html><!-- so the shell half is an HTML comment.

The installer body uses bash arrays, so the contract is curl hanzo.sh | bash, not | sh. The Dockerfile asserts all three properties (shebang, <!DOCTYPE html>, bash -n) and fails the build if any is lost — nothing downstream can catch it, because everything downstream is bytes.

public/install.sh is also served at /install.sh for anyone who wants the installer alone; public/{bot,cli,agents,dev,full,mcp,node,python,rust} are per-bundle shims.

Serving chain

push to main
  -> .github/workflows/sync.yml     carries refs to git.hanzo.ai (nothing else)
  -> .hanzo/workflows/deploy.yml    runs natively on the forge
       docker build .               Dockerfile: pnpm build -> polyglot assert
                                    -> FROM ghcr.io/hanzoai/static:v0.5.1
       docker push                  ghcr.io/hanzoai/hanzo-sh:<short-sha>
  -> hanzoai/universe               infra/k8s/operator/crs/hanzo-sh.yaml
       spec.image.tag: <short-sha>  set by a human, never by the build
  -> hanzoai/ingress                hanzo.sh -> Service hanzo-sh:80 -> :3000

hanzoai/static is a Go binary on scratch. It defaults to -port 3000 -root /public and answers with http.ServeContent, so a file goes out byte-for-byte with no rewrite, minify or injection — which is the only reason a polyglot can be served from an ordinary static server at all. It runs WITHOUT -spa: this app has exactly one route, so a mistyped path must 404 rather than return the installer.

HANZO_STATIC_CSP in the CR is a response header only; it cannot touch the body. It sets script-src 'self', which the page now satisfies outright: the third-party cdn.gpteng.co/gptengineer.js tag is gone from index.html (it was a build-tool artifact the CSP was already blocking and nothing depended on), and so is the CDN font @import.

Not live from here yet

hanzo.sh is served today by a Cloudflare assets Worker named hanzo-sh (wrangler.toml, custom_domain = true), published by hand with pnpm build && npx wrangler@3 deploy. wrangler.toml stays until the image is promoted; deleting it first would strand the live host.

Two things must be reconciled BEFORE anyone pins a tag in the CR:

  1. The CSP can come down to same-origin. main used to @import Geist from cdn.jsdelivr.net, which is why the CR's CSP allows that host for styles and fonts. It does not any more: @hanzo/design ships both Geist faces as woff2 inside the package and the build emits them into dist/assets/, so the page makes no third-party request at all. Drop cdn.jsdelivr.net from HANZO_STATIC_CSP when the image is promoted.
  2. Promotion order. publish an image -> set spec.image.tag in crs/hanzo-sh.yaml -> add - hanzo-sh.yaml to crs/kustomization.yaml -> confirm the pod is Running and that curl <pod>/ | bash -s -- --help prints the installer usage -> only then repoint hanzo.sh DNS off the Worker -> then delete wrangler.toml.

The CR is committed INERT (empty tag, absent from kustomization.yaml). Promoting an App with no image tag takes the host down instead of leaving it alone.

Stack

Vite 5 + React 19 on @hanzo/ui 8.0.38 over the @hanzo/gui 8.0.0 backend (whole @hanzogui/* graph at 8.0.0). One route (src/pages/Index.tsx), no router — hanzoai/static runs without -spa, so the routing decision is already made and a client-side router would only pretend to make it again. pnpm 9; pnpm-lock.yaml is what the build resolves (--frozen-lockfile).

TypeScript is 7.0.2 (the native compiler) and pnpm typecheck runs it directly. typescript-eslint supports only the TS 6 API, so .pnpmfile.cjs gives the @typescript-eslint/* packages [email protected] as their OWN dependency — the documented side-by-side arrangement — while everything else sees 7.0.2. Delete that file when typescript-eslint supports TS >= 7.1 (typescript-eslint#10940).

The gui base sheet is a REAL stylesheet, not runtime injection: scripts/gen-gui-css.mjs (first step of pnpm build) bundles src/gui.config.ts with esbuild and writes config.getCSS() to src/gui.css, main.tsx imports it, and GuiProvider renders with disableInjectCSS. Vite fingerprints the sheet (~1.37 MB raw, ~43 kB gzip) so the browser caches it once. The generated file is committed so dev/typecheck work without a build.

Icons import from the @hanzogui/lucide-icons-2 package ROOT, not /icons/<Name> subpaths: 8.0.0's per-icon exports entries carry no types condition (the .d.ts files exist under types/icons/ but are unreachable), so subpath imports typecheck as any. The root export is typed, and sideEffects: false means Vite tree-shakes it to the same bytes — measured, the JS bundle SHRANK 647 kB -> 531 kB moving to 8.0.0 + root imports. react-native-svg is declared directly because @hanzogui/[email protected] imports it without declaring the peer.

pnpm install
pnpm dev        # vite, :8080
pnpm build      # -> dist/, including the polyglot rewrite
pnpm typecheck
pnpm lint

scripts/polyglot.sh is an unwired duplicate of scripts/build-polyglot.js; only the .js is in pnpm build.

Where the page comes from

There is no Tailwind, no shadcn and no Radix; the 48 generated components/ui/* files and the 27 Radix packages behind them are gone.

  • @hanzo/products — the header's launcher and the whole footer index. The content of both is settled once for the estate; hanzo.sh only declares its own four nav links and its one call-to-action (src/site.ts), because @hanzo/[email protected] has no hanzo.sh SiteId yet. When it does, HEADER collapses to HEADERS['hanzo.sh'].
  • @hanzo/ui/product — SiteNav, SiteFooter, BrandMark, Panel, StatusTag, ToastProvider. The page used to draw two headers and two footers (a Navbar above a Hero that carried its own header, and a hand-written footer of href="#" links below the real one); these are the components that end that.
  • @hanzo/gui — every primitive under it (YStack/XStack/Text/Anchor/ Separator), on the shared scale from @hanzo/ui/gui-config.
  • @hanzo/design — the token layer: monochrome, dark by default, Geist Sans and Geist Mono self-hosted in the package. src/index.css imports it and does nothing else.
  • @hanzo/logo — the mark, through BrandMark. Not a direct dependency: @hanzo/ui already depends on it, and declaring a package nothing here imports is how a dependency list starts lying. @hanzo/brand is deliberately absent too — it is the MULTI-brand registry, and hanzo.sh is one brand. Reaching for it here is how a Lux or Zoo mark ends up on a Hanzo surface.

src/site.ts is the page as data; src/components/ renders it. Responsiveness is flexWrap + minW, not breakpoints: the same tree reflows from 390px to 1280px with one media prop in the whole app.

Two workarounds, both with a delete condition

@hanzo/[email protected] still has two defects this app has to route around. Both are marked in place; remove them when the package is fixed.

  1. src/shims/hanzogui-next-theme.ts — 8.0.38 improved on 8.0.26: ThemeToggle now lazily code-splits ThemeToggleNext behind an error boundary instead of importing it statically. But Rollup still follows the dynamic import at build time, and ThemeToggleNext statically imports @hanzogui/next-theme (an optional peer) — without the alias the build fails on "useThemeSetting" is not exported by "__vite-optional-peer-dep:..." (measured). So vite.config.ts keeps aliasing the specifier to a stub; nothing here renders a theme toggle. Delete when a Vite build passes without it.
  2. src/gui.config.ts — SiteNav still writes $sm meaning "phone" in 8.0.38 (verified in dist/product/SiteNav.js), but v5 defines sm as minWidth: 640, so on the stock scale a phone gets the desktop link row and a desktop gets a hamburger. This app redefines that ONE key as maxWidth: 639.98; nothing else it renders reads sm.