diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff674d8..fc209f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,20 @@ jobs: run: | sudo apt-get update sudo apt-get install -y shellcheck - shellcheck -s sh install.sh scripts/check_install.sh + shellcheck -s sh install.sh scripts/check_install.sh scripts/check_piped_install.sh - name: Python syntax run: python3 -m py_compile scripts/check_skills.py scripts/apply_addons.py bin/hstack-bench bin/hstack-test bin/hstack-pr bin/hstack-visdiff bin/hstack-scan bin/hstack-decisions - name: Skills run: python3 scripts/check_skills.py - name: Installer run: sh scripts/check_install.sh + - name: Site + run: | + cd site + npm ci + npm run build + node scripts/check-no-html.mjs + node scripts/check-pages.mjs + node scripts/check-vercel.mjs + cd .. + sh scripts/check_piped_install.sh diff --git a/README.md b/README.md index 60bf8f8..b4fd1fd 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ Twelve skills for Hermes Agent. Frame the job, shape the change, prove it, and o curl -fsSL https://hstack.sh/install | sh ``` -From a checkout of this repo, `sh install.sh` installs that checkout. A second run replaces the HStack skills and leaves every other skill alone. Pass `--dry-run` to print the plan and write nothing. +The curl form downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone this repository. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. `HSTACK_VERSION=0.1.0` pins `hstack-v0.1.0.tar.gz` instead of the latest alias. The archive is one directory containing the skills, `scripts/apply_addons.py`, and the `bin/` helpers, so `--with` works from the curl form too. -Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` if Hermes lives somewhere else. `install.sh` is for macOS and Linux. On Windows, `install.ps1` takes the same flags. WSL can run `install.sh`. +From a checkout of this repo, `sh install.sh` installs the skills beside the script. A second run replaces the HStack skills and leaves every other skill alone. Pass `--dry-run` to print the plan and write nothing. + +Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` if Hermes lives somewhere else. `install.sh` is for macOS and Linux. On Windows, `https://hstack.sh/install.ps1` takes the same flags and downloads the same archive. WSL can run `install.sh`. ## Why this instead of GStack @@ -35,10 +37,10 @@ GStack's Hermes host puts every skill in the always-on index. HStack puts one ro | | GStack on Hermes | HStack | |---|---:|---:| | Idle index | 1,182 | 204 | -| Planning load | 62,514 of SKILL.md, plus a ~5,600 preamble | 2,500 | -| All SKILL.md bodies | 420,401 | 8,550 | +| Planning load | 62,514 of SKILL.md, plus a ~5,600 preamble | 965 | +| All SKILL.md bodies | 420,401 | 3,820 | -The planning path is `hstack`, `reframe`, and `shape`. 2,500 against 62,514 is about 25× smaller. The idle index is about 6× smaller. HStack's body figures are the v0.1 ceilings. 204 is the index cost of these descriptions, including the `hstack` category header. +The planning path is `hstack`, `reframe`, and `shape`. 965 against 62,514 is under 2% of that context. The idle index is about 6× smaller. These HStack figures are measured from the files in this repo. 204 is the index cost of these descriptions, including the `hstack` category header. The v0.1 ceilings in PLAN.md are 2,500 and 8,550. ## Skills diff --git a/install.ps1 b/install.ps1 index 5d54461..0a8dc52 100644 --- a/install.ps1 +++ b/install.ps1 @@ -27,7 +27,8 @@ Usage: powershell -File install.ps1 [--dry-run] [--uninstall] [--with name,name] Installs into the Hermes home, at skills\hstack. The usual home is .hermes in your profile directory. The default install is skills only. --with adds an optional addon: browser, github, docs, search, perf, test, security, trail. -HSTACK_REF selects the git branch when the script downloads (default: main). +The download form fetches the archive from hstack.sh. +HSTACK_BASE_URL overrides that host. HSTACK_VERSION pins a release. Running this file from a git checkout installs that checkout. '@ } @@ -198,13 +199,23 @@ if ((Test-Path -LiteralPath $Dest) -and -not (Test-Path -LiteralPath $Marker)) { } } +$Url = '' +$Sum = '' if (-not $Mode) { $Mode = 'remote' - $Ref = if ($env:HSTACK_REF) { $env:HSTACK_REF } else { 'main' } - if ($Ref -notmatch '^[A-Za-z0-9._-]+$' -or $Ref.StartsWith('-') -or $Ref.Contains('..')) { - Die 'refusing HSTACK_REF' + $Base = if ($env:HSTACK_BASE_URL) { $env:HSTACK_BASE_URL.TrimEnd('/') } else { 'https://hstack.sh' } + if ($Base -notmatch '^https?://' -or $Base -match '\s|<|>|\.\.') { + Die 'refusing HSTACK_BASE_URL' } - $Url = "https://github.com/Belweave/hstack/archive/refs/heads/$Ref.tar.gz" + $Name = 'hstack.tar.gz' + if ($env:HSTACK_VERSION) { + if ($env:HSTACK_VERSION -notmatch '^[0-9.]+$' -or $env:HSTACK_VERSION.Contains('..')) { + Die 'refusing HSTACK_VERSION' + } + $Name = "hstack-v$($env:HSTACK_VERSION).tar.gz" + } + $Url = "$Base/dl/$Name" + $Sum = "$Base/dl/$Name.sha256" } if ($Dry) { @@ -213,6 +224,7 @@ if ($Dry) { Say "dry-run: source tree $Src" } else { Say "dry-run: would download $Url" + Say "dry-run: would verify $Sum" } Invoke-Addons 'install' exit 0 @@ -221,9 +233,33 @@ if ($Dry) { if ($Mode -eq 'remote') { $Work = Join-Path ([System.IO.Path]::GetTempPath()) ("hstack-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $Work | Out-Null + $RemoteOk = $false try { $archive = Join-Path $Work 'hstack.tar.gz' - Invoke-WebRequest -Uri $Url -OutFile $archive + $sumfile = Join-Path $Work 'hstack.tar.gz.sha256' + Invoke-WebRequest -Uri $Url -OutFile $archive -UseBasicParsing + Invoke-WebRequest -Uri $Sum -OutFile $sumfile -UseBasicParsing + if ((Get-Item -LiteralPath $archive).Length -lt 1 -or (Get-Item -LiteralPath $sumfile).Length -lt 1) { + Die 'download was empty' + } + $stream = [System.IO.File]::OpenRead($archive) + try { + $b0 = $stream.ReadByte() + $b1 = $stream.ReadByte() + } finally { + $stream.Dispose() + } + if ($b0 -ne 0x1f -or $b1 -ne 0x8b) { + Die 'download was not a gzip archive' + } + $expected = ((Get-Content -LiteralPath $sumfile -TotalCount 1) -split '\s+', 2)[0] + if ($expected -notmatch '^[0-9A-Fa-f]{64}$') { + Die 'checksum was not a sha256 digest' + } + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + if ($actual.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { + Die 'checksum mismatch' + } $members = & tar -tzf $archive if ($LASTEXITCODE -ne 0) { Die 'could not read archive' } foreach ($member in $members) { @@ -231,6 +267,13 @@ if ($Mode -eq 'remote') { Die 'archive member is absolute or contains ..' } } + $listing = & tar -tvzf $archive + if ($LASTEXITCODE -ne 0) { Die 'could not list archive' } + foreach ($line in $listing) { + if ($line.StartsWith('l') -or $line.StartsWith('h')) { + Die 'archive contains a link; refusing to extract it' + } + } & tar -xzf $archive -C $Work if ($LASTEXITCODE -ne 0) { Die 'could not extract archive' } $tops = @(Get-ChildItem -LiteralPath $Work -Directory) @@ -240,8 +283,9 @@ if ($Mode -eq 'remote') { if (-not (Test-Path -LiteralPath (Join-Path $Src 'hstack\SKILL.md'))) { Die 'archive is missing skills/hstack/hstack/SKILL.md' } + $RemoteOk = $true } finally { - if ($Mode -eq 'remote' -and (Test-Path -LiteralPath $Work) -and -not $Src) { + if (-not $RemoteOk -and (Test-Path -LiteralPath $Work)) { Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue } } @@ -255,6 +299,12 @@ if ($links.Count -gt 0) { Die 'refusing to copy a link or special file from the skill tree' } +$ver = '0.1.0' +$versionFile = if ($AddonRoot) { Join-Path $AddonRoot 'VERSION' } else { $null } +if ($versionFile -and (Test-Path -LiteralPath $versionFile)) { + $digits = ([System.IO.File]::ReadAllText($versionFile) -replace '[^0-9.]', '') + if ($digits) { $ver = $digits } +} $Parent = Split-Path -Parent $Dest if (-not (Test-Path -LiteralPath $Parent)) { New-Item -ItemType Directory -Path $Parent | Out-Null @@ -262,10 +312,16 @@ if (-not (Test-Path -LiteralPath $Parent)) { $Stage = Join-Path $Parent ('.hstack-stage.' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $Stage | Out-Null Copy-Item -Path (Join-Path $Src '*') -Destination $Stage -Recurse -Force +$stagedVersion = Join-Path $Stage 'VERSION' +if (Test-Path -LiteralPath $stagedVersion) { + $digits = ([System.IO.File]::ReadAllText($stagedVersion) -replace '[^0-9.]', '') + if ($digits) { $ver = $digits } + Remove-Item -LiteralPath $stagedVersion -Force +} if (-not (Test-Path -LiteralPath (Join-Path $Stage 'hstack\SKILL.md'))) { Die 'install tree is missing hstack/SKILL.md' } -Set-Content -LiteralPath (Join-Path $Stage '.hstack-managed') -Value "managed-by: hstack install.sh`nversion: 0.1.0`n" -NoNewline -Encoding ascii +Set-Content -LiteralPath (Join-Path $Stage '.hstack-managed') -Value "managed-by: hstack install.sh`nversion: $ver`n" -NoNewline -Encoding ascii if (Test-Path -LiteralPath $Dest) { $Prev = Join-Path $Parent ('.hstack-prev.' + $PID) diff --git a/install.sh b/install.sh index 5ba1981..896c766 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,13 @@ # Install HStack into Hermes Agent. # curl -fsSL https://hstack.sh/install | sh # curl -fsSL https://hstack.sh/install | sh -s -- --uninstall -# sh install.sh [--dry-run] [--uninstall] +# sh install.sh [--dry-run] [--uninstall] [--with name,name] # Skills go to ${HERMES_HOME:-$HOME/.hermes}/skills/hstack. # A second run replaces an install this script created. It will not touch # any other skill, and it will not delete a directory it did not mark. +# The curl form downloads the archive from hstack.sh. The repository is private, +# so this script does not fetch from a git host. The archive also carries the +# addon helpers under bin/ and scripts/apply_addons.py. set -eu @@ -25,7 +28,8 @@ usage() { 'Installs into the Hermes home, at skills/hstack. The usual home is .hermes in your home directory.' \ 'The default install is skills only. --with adds an optional addon:' \ 'browser, github, docs, search, perf, test, security, trail.' \ - 'HSTACK_REF selects the git branch when the script downloads (default: main).' \ + 'The curl form downloads the archive from hstack.sh.' \ + 'HSTACK_BASE_URL overrides that host. HSTACK_VERSION pins a release.' \ 'Running this file from a git checkout installs that checkout.' } @@ -251,6 +255,7 @@ fi MODE= SRC= URL= +SUM= if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/skills/hstack/hstack/SKILL.md" ]; then MODE=local SRC="$SCRIPT_DIR/skills/hstack" @@ -259,14 +264,26 @@ fi if [ -z "$MODE" ]; then MODE=remote - REF=${HSTACK_REF:-main} - case "$REF" in - ''|-*|*..*|*[[:space:]]*|*'/'*) die "refusing HSTACK_REF" ;; + BASE=${HSTACK_BASE_URL:-https://hstack.sh} + case "$BASE" in + */) BASE=${BASE%/} ;; esac - case "$REF" in - *[!A-Za-z0-9._-]*) die "refusing HSTACK_REF" ;; + case "$BASE" in + http://*|https://*) ;; + *) die "refusing HSTACK_BASE_URL" ;; esac - URL="https://github.com/Belweave/hstack/archive/refs/heads/${REF}.tar.gz" + case "$BASE" in + *[[:space:]]*|*'<'*|*'..'*) die "refusing HSTACK_BASE_URL" ;; + esac + NAME=hstack.tar.gz + if [ -n "${HSTACK_VERSION:-}" ]; then + case "$HSTACK_VERSION" in + ''|*[!0-9.]*|*..*) die "refusing HSTACK_VERSION" ;; + esac + NAME="hstack-v${HSTACK_VERSION}.tar.gz" + fi + URL="$BASE/dl/$NAME" + SUM="$BASE/dl/${NAME}.sha256" fi if [ "$DRY" -eq 1 ]; then @@ -275,23 +292,57 @@ if [ "$DRY" -eq 1 ]; then say "dry-run: source tree $SRC" else say "dry-run: would download $URL" + say "dry-run: would verify $SUM" fi run_addons install exit 0 fi +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk 'NR==1 { print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk 'NR==1 { print $1 }' + else + die "sha256sum or shasum is required" + fi +} + if [ "$MODE" = remote ]; then command -v curl >/dev/null 2>&1 || die "curl is required to download HStack" command -v tar >/dev/null 2>&1 || die "tar is required to download HStack" WORK=$(mktemp -d) archive="$WORK/hstack.tar.gz" + sumfile="$WORK/hstack.tar.gz.sha256" curl -fsSL -o "$archive" "$URL" || die "download failed" - if [ ! -s "$archive" ]; then + curl -fsSL -o "$sumfile" "$SUM" || die "checksum download failed" + if [ ! -s "$archive" ] || [ ! -s "$sumfile" ]; then die "download was empty" fi + magic=$(od -An -t x1 -N 2 "$archive" | tr -d ' \n') + case "$magic" in + 1f8b) ;; + *) die "download was not a gzip archive" ;; + esac + expected=$(awk 'NR==1 { print $1 }' "$sumfile") + case "$expected" in + *[!0-9A-Fa-f]*|'') die "checksum was not a sha256 digest" ;; + esac + elen=$(printf '%s' "$expected" | wc -c | tr -d ' ') + if [ "$elen" -ne 64 ]; then + die "checksum was not a sha256 digest" + fi + actual=$(hash_file "$archive") + if [ "$actual" != "$expected" ]; then + die "checksum mismatch" + fi tar -tzf "$archive" > "$WORK/members" || die "could not read archive" while IFS= read -r member || [ -n "$member" ]; do - case "$member" in + norm=$member + case "$norm" in + ./*) norm=${norm#./} ;; + esac + case "$norm" in ''|/*|*..*) die "archive member is absolute or contains .." ;; esac done < "$WORK/members" @@ -316,26 +367,40 @@ if [ "$MODE" = remote ]; then SRC="$top/skills/hstack" ADDON_ROOT=$top if [ ! -f "$SRC/hstack/SKILL.md" ]; then - die "archive is missing skills/hstack/hstack/SKILL.md" + die "archive is missing hstack/SKILL.md" fi fi if [ ! -f "$SRC/hstack/SKILL.md" ]; then - die "missing skills/hstack/hstack/SKILL.md" + die "missing hstack/SKILL.md" fi if find "$SRC" \( -type l -o -type b -o -type c -o -type p -o -type s \) -print | grep -q .; then die "refusing to copy a link or special file from the skill tree" fi +ver=0.1.0 +if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/VERSION" ]; then + got=$(tr -cd '0-9.' < "$ADDON_ROOT/VERSION") + if [ -n "$got" ]; then + ver=$got + fi +fi parent=$(dirname "$DEST") mkdir -p "$parent" STAGE=$(mktemp -d "${parent}/.hstack-stage.XXXXXX") cp -R "$SRC/." "$STAGE/" +if [ -f "$STAGE/VERSION" ]; then + got=$(tr -cd '0-9.' < "$STAGE/VERSION") + if [ -n "$got" ]; then + ver=$got + fi + rm -f "$STAGE/VERSION" +fi if [ ! -f "$STAGE/hstack/SKILL.md" ]; then die "install tree is missing hstack/SKILL.md" fi -printf '%s\n' 'managed-by: hstack install.sh' 'version: 0.1.0' > "$STAGE/.hstack-managed" +printf '%s\n' 'managed-by: hstack install.sh' "version: $ver" > "$STAGE/.hstack-managed" if [ -d "$DEST" ]; then PREV="${parent}/.hstack-prev.$$" diff --git a/scripts/check_install.sh b/scripts/check_install.sh index a340757..8851628 100755 --- a/scripts/check_install.sh +++ b/scripts/check_install.sh @@ -12,6 +12,16 @@ fail() { exit 1 } +if grep -q 'github.com' "$INSTALL"; then + fail "installer must not fetch from GitHub" +fi +if grep -q 'github.com' "$ROOT/install.ps1"; then + fail "install.ps1 must not fetch from GitHub" +fi +if grep -q 'HSTACK_REF' "$INSTALL" "$ROOT/install.ps1"; then + fail "installers still select a git ref" +fi + count_skills() { _n=0 for _dir in "$1"/skills/hstack/*; do diff --git a/scripts/check_piped_install.sh b/scripts/check_piped_install.sh new file mode 100755 index 0000000..b77f504 --- /dev/null +++ b/scripts/check_piped_install.sh @@ -0,0 +1,168 @@ +#!/bin/sh +# Piped install against the local site build, with GNU tar and with BSD tar plus shasum. +set -eu + +ROOT=$(CDPATH='' cd "$(dirname "$0")/.." && pwd -P) +SITE=$ROOT/site +TMP=$(mktemp -d) +PORT=${PORT:-4179} +PID= + +fail() { + printf 'check_piped: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + if [ -n "$PID" ]; then + kill "$PID" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" +} +trap cleanup EXIT + +if [ ! -f "$SITE/dist/install.sh" ] || [ ! -f "$SITE/dist/dl/hstack.tar.gz" ]; then + fail "site/dist is missing; run npm run build in site/" +fi + +PORT=$PORT node "$SITE/scripts/preview.mjs" >"$TMP/preview.log" 2>&1 & +PID=$! +i=0 +while ! curl -fsS "http://127.0.0.1:$PORT/install" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -gt 50 ]; then + fail "preview did not start" + fi + sleep 0.1 +done + +count_skills() { + _n=0 + for _dir in "$1"/skills/hstack/*; do + if [ -f "$_dir/SKILL.md" ]; then + _n=$((_n + 1)) + fi + done + printf '%s\n' "$_n" +} + +install_with() { + label=$1 + path_value=$2 + home=$TMP/$label + mkdir -p "$home" + if [ -n "$path_value" ]; then + PATH="$path_value" HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh' + else + HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh' + fi + test -f "$home/hermes/skills/hstack/.hstack-managed" || fail "$label missing marker" + test -f "$home/hermes/skills/hstack/hstack/SKILL.md" || fail "$label missing router" + test -f "$home/hermes/skills/hstack/hstack/references/frame.md" || fail "$label missing playbook" + test "$(count_skills "$home/hermes")" -eq 12 || fail "$label expected 12 skills" + test ! -e "$home/hermes/skills/hstack/VERSION" || fail "$label left VERSION in the skill tree" + grep -q 'version: 0.1.0' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version" + diff -q "$ROOT/skills/hstack/reframe/SKILL.md" "$home/hermes/skills/hstack/reframe/SKILL.md" >/dev/null \ + || fail "$label skill bytes differ" + if [ -n "$path_value" ]; then + PATH="$path_value" HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh -s -- --dry-run' >"$TMP/$label.dry" + PATH="$path_value" HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh -s -- --uninstall' + else + HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh -s -- --dry-run' >"$TMP/$label.dry" + HOME="$home" HERMES_HOME="$home/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh -s -- --uninstall' + fi + grep -q "http://127.0.0.1:$PORT/dl/hstack.tar.gz" "$TMP/$label.dry" || fail "$label dry-run omitted the archive URL" + test ! -d "$home/hermes/skills/hstack" || fail "$label uninstall left the tree" +} + +install_with linux "" + +WITH=$TMP/with +mkdir -p "$WITH" +HOME="$WITH" HERMES_HOME="$WITH/hermes" HSTACK_BASE_URL="http://127.0.0.1:$PORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh -s -- --with browser' +test -x "$WITH/hermes/hstack/bin/hstack-visdiff" || fail "piped --with browser missing visdiff" +test -f "$WITH/hermes/hstack/apply_addons.py" || fail "piped --with browser missing apply_addons.py" +grep -q 'hstack_playwright' "$WITH/hermes/config.yaml" || fail "piped --with browser missing playwright" +test -f "$WITH/hermes/skills/hstack/hstack/SKILL.md" || fail "piped --with browser missing skills" +test ! -e "$WITH/hermes/skills/hstack/bin" || fail "helpers landed in the skill tree" +test ! -e "$WITH/hermes/skills/hstack/VERSION" || fail "piped --with browser left VERSION in the skill tree" + +MAC=$TMP/macbin +mkdir -p "$MAC" +link() { + command -v "$1" >/dev/null 2>&1 || fail "missing $1" + ln -sf "$(command -v "$1")" "$MAC/$2" +} +link curl curl +link shasum shasum +link awk awk +link mktemp mktemp +link find find +link grep grep +link od od +link tr tr +link wc wc +link cp cp +link mv mv +link rm rm +link mkdir mkdir +link dirname dirname +link basename basename +link pwd pwd +ln -sf "$(command -v bsdtar)" "$MAC/tar" +ln -sf /bin/sh "$MAC/sh" +if [ -x "$MAC/sha256sum" ]; then + fail "macOS-like PATH still has sha256sum" +fi +install_with macos "$MAC:/bin" + +HOME=$TMP/pin HERMES_HOME=$TMP/pin/hermes HSTACK_BASE_URL="http://127.0.0.1:$PORT" HSTACK_VERSION=0.1.0 \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh' +test -f "$TMP/pin/hermes/skills/hstack/shape/SKILL.md" || fail "version pin did not install" + +mkdir -p "$TMP/bad/dl" +cp "$SITE/dist/dl/hstack.tar.gz" "$TMP/bad/dl/hstack.tar.gz" +printf '%s\n' '0000000000000000000000000000000000000000000000000000000000000000 hstack.tar.gz' > "$TMP/bad/dl/hstack.tar.gz.sha256" +cp "$SITE/dist/install.sh" "$TMP/bad/install.sh" +BADPORT=$((PORT + 1)) +python3 - "$TMP/bad" "$BADPORT" << 'PY' & +import http.server, socketserver, sys +root, port = sys.argv[1], int(sys.argv[2]) +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root, **kwargs) + def log_message(self, fmt, *args): + return + def do_GET(self): + path = self.path.split("?", 1)[0] + if path in ("/install", "/install/"): + self.path = "/install.sh" + return super().do_GET() +with socketserver.TCPServer(("127.0.0.1", port), Handler) as httpd: + httpd.serve_forever() +PY +BPID=$! +trap 'kill "$PID" "$BPID" >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT +j=0 +while ! curl -fsS "http://127.0.0.1:$BADPORT/install" >/dev/null 2>&1; do + j=$((j + 1)) + if [ "$j" -gt 50 ]; then + fail "bad checksum server did not start" + fi + sleep 0.1 +done +if HOME=$TMP/badhome HERMES_HOME=$TMP/badhome/hermes HSTACK_BASE_URL="http://127.0.0.1:$BADPORT" \ + /bin/sh -c 'curl -fsSL "$HSTACK_BASE_URL/install" | sh' >"$TMP/bad.out" 2>"$TMP/bad.err"; then + fail "bad checksum should fail" +fi +grep -q 'checksum mismatch' "$TMP/bad.err" || fail "bad checksum was not reported" +test ! -d "$TMP/badhome/hermes/skills/hstack" || fail "bad checksum wrote a skill tree" + +printf '%s\n' 'check_piped: linux, addons, macos-like, version pin, and checksum mismatch ok' diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..0c199f7 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.vercel +src/data.gen.js diff --git a/site/DEPLOY.md b/site/DEPLOY.md new file mode 100644 index 0000000..06a52a5 --- /dev/null +++ b/site/DEPLOY.md @@ -0,0 +1,51 @@ +# Deploy + +The primary host is Vercel, project `hstack`, on Preetham's Pro plan. Cloudflare Pages on the belweave account, Free plan, is the fallback. Do not deploy from this note; the project owner runs the deploy. + +## Vercel + +Connect the GitHub repository with the root directory set to `site`, or deploy from a checkout with `vercel deploy --prod --cwd site`. `site/vercel.json` pins the build. + +| Setting | Value | +|---|---| +| Project | `hstack` | +| Account | Preetham's Pro plan | +| Root directory | `site` | +| Framework preset | Other (`framework` is `null`) | +| Build command | `npm run build` | +| Output directory | `dist` | + +`cleanUrls` is on. `/install` and `/install/` rewrite to `install.sh` and are served as `text/plain; charset=utf-8` with `Cache-Control: public, max-age=60`. Any other path under `/install/` rewrites to a plain-text 404. `.tar.gz` is `application/gzip`. `.sha256` is `text/plain`. `www.hstack.sh` redirects to `https://hstack.sh` with status 308. A request that still arrives as HTTP on `hstack.sh` redirects to HTTPS with status 308. + +Turn Deployment Protection off for production. A protected deploy returns an HTML login page, and `curl | sh` would not receive the installer. + +## hstack.sh at Porkbun + +Leave the Porkbun nameservers alone. The registrar stays Porkbun. Vercel does not need to host DNS. + +1. In the Vercel project `hstack`, open Settings, then Domains. Add `hstack.sh` and `www.hstack.sh`. +2. The apex card shows an A record. For most projects the value is `76.76.21.21`. Use the address on the card if it differs. +3. At Porkbun, edit DNS for `hstack.sh`. Remove any existing apex A or AAAA record. Add one A record: host `@`, value from the apex card (usually `76.76.21.21`). Do not add an AAAA record. +4. Add a CNAME for `www`. The value is the CNAME target on the `www` domain card. For many projects that target is `cname.vercel-dns.com`. A newer project may show a specific name such as `cname.vercel-dns-0.com`. Use the card. +5. Wait until both cards say the configuration is valid. Vercel issues the certificates. + +`www.hstack.sh` answers 308 to the apex. Before DNS is in place, the deployment hostname on the project card (a `*.vercel.app` host) is what `HSTACK_BASE_URL` should point at. + +## What the deploy serves + +- `https://hstack.sh/install` and `https://hstack.sh/install/` are `install.sh`, `Content-Type: text/plain; charset=utf-8`. +- `https://hstack.sh/install.ps1` is the Windows installer, also `text/plain`. +- Any other path under `/install/` is a plain-text 404 (`not found`), not an HTML page. +- `curl -fsSL hstack.sh/install` follows HTTP to HTTPS and receives the script. `curl -fsSL www.hstack.sh/install` follows the 308 to the apex and receives the same script. +- `https://hstack.sh/dl/hstack.tar.gz` is the latest archive. `hstack.tar.gz.sha256` is its digest. +- `https://hstack.sh/dl/hstack-v0.1.0.tar.gz` is that same release by version. The installer checks the digest before it extracts. + +The archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `bin/` (the addon helpers), and `VERSION`. + +## Cloudflare Pages fallback + +Use this only if the Vercel project is not the host. Same build: root directory `site`, build command `npm run build`, output directory `dist`, no framework preset. Account belweave, Free plan. + +`site/_headers` and `site/_redirects` are copied into `dist/`. Pages reads them from the output and does not serve them as assets. `site/functions/` stays in the project root. Pages compiles it from there. `functions/install/[...path].js` returns the plain-text 404, because Pages does not honor a 404 status in `_redirects`. `functions/_middleware.js` redirects HTTP to HTTPS when the request is still http. Leave Always Use HTTPS on. + +Pages will not activate the apex while DNS stays at Porkbun. The fallback path is to add the `hstack.sh` zone on the belweave account and set Porkbun's nameservers to the Cloudflare pair. That replaces the Vercel A record and the `www` CNAME. Do not point the apex at both hosts. A project named `hstack` is also reachable at `hstack.pages.dev` before that cutover. diff --git a/site/_headers b/site/_headers new file mode 100644 index 0000000..6f297a1 --- /dev/null +++ b/site/_headers @@ -0,0 +1,39 @@ +/install/* + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + +/install + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + Cache-Control: public, max-age=60 + +/install/ + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + Cache-Control: public, max-age=60 + +/install.sh + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + Cache-Control: public, max-age=60 + +/install.ps1 + Content-Type: text/plain; charset=utf-8 + X-Content-Type-Options: nosniff + Cache-Control: public, max-age=60 + +/dl/hstack.tar.gz + Content-Type: application/gzip + Cache-Control: public, max-age=300 + +/dl/hstack.tar.gz.sha256 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300 + +/dl/hstack-v0.1.0.tar.gz + Content-Type: application/gzip + Cache-Control: public, max-age=31536000, immutable + +/dl/hstack-v0.1.0.tar.gz.sha256 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=31536000, immutable diff --git a/site/_redirects b/site/_redirects new file mode 100644 index 0000000..7fd46ce --- /dev/null +++ b/site/_redirects @@ -0,0 +1,2 @@ +/install /install.sh 200 +/install/ /install.sh 200 diff --git a/site/fonts/inter-bold.woff2 b/site/fonts/inter-bold.woff2 new file mode 100644 index 0000000..e69189c Binary files /dev/null and b/site/fonts/inter-bold.woff2 differ diff --git a/site/fonts/inter-regular.woff2 b/site/fonts/inter-regular.woff2 new file mode 100644 index 0000000..9348abc Binary files /dev/null and b/site/fonts/inter-regular.woff2 differ diff --git a/site/functions/_middleware.js b/site/functions/_middleware.js new file mode 100644 index 0000000..be2a98d --- /dev/null +++ b/site/functions/_middleware.js @@ -0,0 +1,9 @@ +export async function onRequest(context) { + const url = new URL(context.request.url); + const forwarded = context.request.headers.get("x-forwarded-proto"); + if (url.protocol === "http:" || forwarded === "http") { + url.protocol = "https:"; + return Response.redirect(url.toString(), 301); + } + return context.next(); +} diff --git a/site/functions/install/[...path].js b/site/functions/install/[...path].js new file mode 100644 index 0000000..c7a7f54 --- /dev/null +++ b/site/functions/install/[...path].js @@ -0,0 +1,9 @@ +export function onRequest() { + return new Response("not found\n", { + status: 404, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..eac7c58 --- /dev/null +++ b/site/index.html @@ -0,0 +1,158 @@ + + + + + + HStack · Everything Hermes needs + + + + + + + + + + + + + + + + + + + + +
+ + + HStack + + + GitHub +
+
+
+
+
+

Everything Hermes needs. Nothing it doesn't.

+
+ curl -fsSL https://hstack.sh/install | sh + +
+
    +
  • MIT
  • +
  • Designed for Hermes Agent
  • +
  • 12 skills
  • +
  • ~204 idle tokens
  • +
+
+ +
+

+
+
+
+

HStack
planning load

+

965tokens

+
+
+ +
+

GStack
planning load

+

62,514tokens

+
+
+

Under 2% of the context. Built for Hermes.

+

A community project. Not affiliated with Nous Research.

+
+
+
+ +
+

The index stays small.

+

Hermes reads every skill description on each turn. HStack keeps one short router there. A playbook loads only when the task needs it. Counts use Hermes's estimator, ceil(bytes / 4).

+ +
+ +
+

Twelve skills.

+

One line in the index. The body loads when you ask for that skill.

+
+
+ +
+

One router. One playbook.

+

/hstack is the skill to remember. It opens the route catalog and loads one playbook. Everything else stays on disk.

+
    +
  1. frameDeciding what to build. Load reframe, then stop or hand off.
  2. +
  3. featureNew or changed behavior. Shape it, implement it, prove it.
  4. +
  5. fixSomething is broken. Use systematic-debugging, then prove the fix.
  6. +
  7. checkThe diff needs a hard look. Council only if you asked for other models.
  8. +
  9. landThe work should become a pull request. Load ship.
  10. +
+

A one-line edit does not need a playbook. If you name another HStack skill, that skill loads and the router steps aside.

+
+ +
+

Use it in the session.

+

Hermes lists the skills under hstack. Ask with the skill name.

+
/hstack the export drops rows over 10 MB
+/reframe
+/ship
+/council
+
+ +
+

Install

+

macOS and Linux. The script is safe to run twice. It only replaces a tree it created.

+
+
+
+

Install

+ +
+
curl -fsSL https://hstack.sh/install | sh
+
+
+
+

Dry run

+ +
+
curl -fsSL https://hstack.sh/install | sh -s -- --dry-run
+
+
+
+

Uninstall

+ +
+
curl -fsSL https://hstack.sh/install | sh -s -- --uninstall
+
+
+

From a checkout of the repo, sh install.sh installs the skills next to the script. HSTACK_BASE_URL points the curl form at another host, which is how you try a *.vercel.app URL before DNS. Optional add-ons stay off until you pass --with.

+
+
+ + + + diff --git a/site/package-lock.json b/site/package-lock.json new file mode 100644 index 0000000..0d07097 --- /dev/null +++ b/site/package-lock.json @@ -0,0 +1,497 @@ +{ + "name": "hstack-site", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hstack-site", + "devDependencies": { + "esbuild": "0.25.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + } + } +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..a011fa6 --- /dev/null +++ b/site/package.json @@ -0,0 +1,13 @@ +{ + "name": "hstack-site", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "preview": "node scripts/preview.mjs", + "check": "node scripts/check-no-html.mjs && node scripts/check-pages.mjs && node scripts/check-vercel.mjs" + }, + "devDependencies": { + "esbuild": "0.25.10" + } +} diff --git a/site/public/404.html b/site/public/404.html new file mode 100644 index 0000000..4a84425 --- /dev/null +++ b/site/public/404.html @@ -0,0 +1,20 @@ + + + + + + Not found · HStack + + + + +
+

That page is not here.

+

Back to HStack

+
+ + diff --git a/site/public/favicon.svg b/site/public/favicon.svg new file mode 100644 index 0000000..f0a64b1 --- /dev/null +++ b/site/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/site/public/install-404.txt b/site/public/install-404.txt new file mode 100644 index 0000000..7b69a4c --- /dev/null +++ b/site/public/install-404.txt @@ -0,0 +1 @@ +not found diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs new file mode 100644 index 0000000..d30583d --- /dev/null +++ b/site/scripts/build.mjs @@ -0,0 +1,223 @@ +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, copyFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import zlib from "node:zlib"; +import esbuild from "esbuild"; + +const site = path.resolve(import.meta.dirname, ".."); +const root = path.resolve(site, ".."); +const dist = path.join(site, "dist"); +const skillsRoot = path.join(root, "skills", "hstack"); + +const ORDER = [ + "hstack", + "reframe", + "shape", + "prove", + "council", + "ship", + "takeover", + "modernize", + "blast", + "swarm", + "arena", + "principles", +]; + +const GSTACK = { idle: 1182, planning: 62514, bodies: 420401 }; + +function tokens(text, label) { + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes !== text.length) throw new Error(`${label} is not ASCII`); + return Math.ceil(bytes / 4); +} + +function field(md, key, file) { + const match = md.match(new RegExp(`^${key}:\\s*(.*)$`, "m")); + if (!match || !match[1].trim()) throw new Error(`${file} is missing ${key}`); + return match[1].trim(); +} + +function fmt(value) { + return new Intl.NumberFormat("en-US").format(value); +} + +function esc(value) { + return value.replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[ch])); +} + +const skills = ORDER.map((name) => { + const file = path.join(skillsRoot, name, "SKILL.md"); + const md = readFileSync(file, "utf8"); + const description = field(md, "description", file); + const version = field(md, "version", file); + const row = ` - ${name}: ${description}`; + return { + name, + description, + version, + idle: tokens(row, row), + body: tokens(md, file), + }; +}); + +const version = skills[0].version; +if (!skills.every((skill) => skill.version === version)) { + throw new Error("skill versions differ"); +} +if (version !== "0.1.0") { + throw new Error(`version is ${version}; dist publishes hstack-v0.1.0.tar.gz`); +} + +const idle = tokens(" hstack:", "category") + skills.reduce((sum, skill) => sum + skill.idle, 0); +const planning = ["hstack", "reframe", "shape"].reduce((sum, name) => { + return sum + skills.find((skill) => skill.name === name).body; +}, 0); +const bodies = skills.reduce((sum, skill) => sum + skill.body, 0); + +if (idle !== 204 || planning !== 965 || bodies !== 3820) { + throw new Error(`measured idle ${idle}, planning ${planning}, bodies ${bodies}`); +} + +const stack = skills.map((skill) => { + const label = `${skill.name}. ${skill.description} ${skill.idle} idle tokens, ${fmt(skill.body)} body tokens.`; + return `

${esc(skill.description)}

`; +}).join(""); + +const grid = skills.map((skill) => { + return `

${esc(skill.name)}

${esc(skill.description)}

${skill.idle} idle · ${fmt(skill.body)} body

`; +}).join(""); + +function meter(title, hstack, gstack) { + const share = (hstack / gstack).toFixed(6); + return `

${title}

HStack
${fmt(hstack)}
GStack
${fmt(gstack)}
`; +} + +const compare = `
${meter("Idle, every turn", idle, GSTACK.idle)}${meter("Planning load", planning, GSTACK.planning)}${meter("All skill bodies", bodies, GSTACK.bodies)}
`; + +const css = await esbuild.transform(readFileSync(path.join(site, "src", "styles.css"), "utf8"), { + loader: "css", + minify: true, +}); + +let html = readFileSync(path.join(site, "index.html"), "utf8"); +html = html.replace("/*__CSS__*/", () => css.code); +html = html.replace("", () => stack); +html = html.replace("", () => grid); +html = html.replace("", () => compare); +if (html.includes("__")) throw new Error("unreplaced template marker"); + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(path.join(dist, "assets"), { recursive: true }); +mkdirSync(path.join(dist, "dl"), { recursive: true }); +mkdirSync(path.join(dist, "fonts"), { recursive: true }); +writeFileSync(path.join(dist, "index.html"), html); +cpSync(path.join(site, "public"), dist, { recursive: true }); +cpSync(path.join(site, "fonts"), path.join(dist, "fonts"), { recursive: true }); +copyFileSync(path.join(root, ".github", "social-preview.png"), path.join(dist, "social-preview.png")); +copyFileSync(path.join(root, "install.sh"), path.join(dist, "install.sh")); +copyFileSync(path.join(root, "install.ps1"), path.join(dist, "install.ps1")); +copyFileSync(path.join(site, "_headers"), path.join(dist, "_headers")); +copyFileSync(path.join(site, "_redirects"), path.join(dist, "_redirects")); + +await esbuild.build({ + entryPoints: [path.join(site, "src", "main.js")], + bundle: true, + minify: true, + format: "esm", + target: "es2020", + outfile: path.join(dist, "assets", "app.js"), +}); + +const jsGzip = zlib.gzipSync(readFileSync(path.join(dist, "assets", "app.js"))).length; +if (jsGzip > 250 * 1024) throw new Error(`app.js is ${jsGzip} bytes gzipped`); + +const helpers = [ + "hstack-bench", + "hstack-decisions", + "hstack-pr", + "hstack-scan", + "hstack-test", + "hstack-visdiff", +]; +const stage = mkdtempSync(path.join(tmpdir(), "hstack-skills-")); +try { + const top = path.join(stage, "hstack"); + mkdirSync(path.join(top, "skills"), { recursive: true }); + mkdirSync(path.join(top, "scripts"), { recursive: true }); + mkdirSync(path.join(top, "bin"), { recursive: true }); + cpSync(skillsRoot, path.join(top, "skills", "hstack"), { recursive: true }); + cpSync(path.join(root, "scripts", "apply_addons.py"), path.join(top, "scripts", "apply_addons.py")); + for (const helper of helpers) { + cpSync(path.join(root, "bin", helper), path.join(top, "bin", helper)); + } + writeFileSync(path.join(top, "VERSION"), `${version}\n`); + const versioned = path.join(dist, "dl", `hstack-v${version}.tar.gz`); + execFileSync("tar", [ + "--format=ustar", + "--owner=0", + "--group=0", + "--numeric-owner", + "-czf", + versioned, + "-C", + stage, + "hstack", + ], { stdio: "inherit" }); + const members = execFileSync("tar", ["-tzf", versioned], { encoding: "utf8" }).trim().split("\n"); + const tops = new Set(members.map((member) => member.replace(/^\.\//, "").split("/")[0]).filter(Boolean)); + if (tops.size !== 1 || !tops.has("hstack")) { + throw new Error(`archive must have one top-level directory, saw ${[...tops].join(",")}`); + } + for (const member of members) { + const norm = member.replace(/^\.\//, ""); + if (!norm || norm.startsWith("/") || norm.split("/").includes("..")) { + throw new Error(`unsafe archive member ${member}`); + } + } + const required = [ + "hstack/VERSION", + "hstack/skills/hstack/hstack/SKILL.md", + "hstack/scripts/apply_addons.py", + ...helpers.map((helper) => `hstack/bin/${helper}`), + ]; + for (const name of required) { + if (!members.some((member) => member.replace(/^\.\//, "") === name)) { + throw new Error(`archive is missing ${name}`); + } + } + const listing = execFileSync("tar", ["-tvzf", versioned], { encoding: "utf8" }); + for (const line of listing.split("\n")) { + if (line.startsWith("l") || line.startsWith("h")) { + throw new Error(`archive contains a link: ${line}`); + } + } + for (const helper of helpers) { + const line = listing.split("\n").find((entry) => entry.endsWith(`hstack/bin/${helper}`)); + if (!line || !/^.r.x/.test(line)) { + throw new Error(`archive helper is not executable: ${helper}`); + } + } + const latest = path.join(dist, "dl", "hstack.tar.gz"); + copyFileSync(versioned, latest); + const hash = crypto.createHash("sha256").update(readFileSync(versioned)).digest("hex"); + writeFileSync(`${versioned}.sha256`, `${hash} hstack-v${version}.tar.gz\n`); + writeFileSync(`${latest}.sha256`, `${hash} hstack.tar.gz\n`); + for (const published of ["install.sh", "install.ps1"]) { + if (readFileSync(path.join(dist, published), "utf8").includes("github.com")) { + throw new Error(`published ${published} still points at GitHub`); + } + } + console.log(`idle ${idle} planning ${planning} bodies ${bodies} js ${jsGzip} gzip ${hash.slice(0, 12)}`); +} finally { + rmSync(stage, { recursive: true, force: true }); +} diff --git a/site/scripts/check-no-html.mjs b/site/scripts/check-no-html.mjs new file mode 100644 index 0000000..d89967d --- /dev/null +++ b/site/scripts/check-no-html.mjs @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { createPreview } from "./preview.mjs"; + +const dist = path.resolve(import.meta.dirname, "..", "dist"); +const html = readFileSync(path.join(dist, "index.html"), "utf8"); +const server = createPreview(); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const { port } = server.address(); +const base = `http://127.0.0.1:${port}`; + +function fail(message) { + console.error(`check-no-html: ${message}`); + process.exitCode = 1; +} + +const urls = new Set(["/favicon.svg", "/social-preview.png", "/install", "/install/", "/install.sh", "/install.ps1"]); +for (const match of html.matchAll(/(?:src|href)="(\/[^"]+)"/g)) urls.add(match[1]); +const style = html.match(/