From d5b0eced317f67985fcfbfc240575f9a45ca4b46 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 09:14:36 +0000 Subject: [PATCH 1/7] binaries + ci + install script Signed-off-by: nonam3e --- .github/workflows/publish.yml | 110 +++++++++++ install.sh | 330 +++++++++++++++++++++++++++++++++ package.json | 3 + pnpm-lock.yaml | 289 +++++++++++++++++++++++++++++ scripts/build-sea.mjs | 188 +++++++++++++++++++ scripts/lib/common.mjs | 26 +++ scripts/release-manifest.mjs | 71 +++++++ scripts/sea-version-stub.js | 3 + src/run.ts | 9 +- src/version.ts | 6 + tests/release-manifest.test.ts | 115 ++++++++++++ 11 files changed, 1146 insertions(+), 4 deletions(-) create mode 100755 install.sh create mode 100644 scripts/build-sea.mjs create mode 100644 scripts/lib/common.mjs create mode 100644 scripts/release-manifest.mjs create mode 100644 scripts/sea-version-stub.js create mode 100644 src/version.ts create mode 100644 tests/release-manifest.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6857e86..d85b809 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,3 +61,113 @@ jobs: tag_name: v${{ env.version }} name: v${{ env.version }} generate_release_notes: true + + # macos-13 was retired in December 2025; macos-15-intel is the x64 runner. + # Node.js does not publish linux-arm64-musl, so Alpine arm64 stays on npm. + binaries: + name: Binary (${{ matrix.platform }}) + needs: publish + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + smoke: true + # The musl binary cannot run on the glibc builder, so it gets no smoke test. + - os: ubuntu-latest + platform: linux-x64-musl + - os: ubuntu-24.04-arm + platform: linux-arm64 + smoke: true + - os: macos-latest + platform: darwin-arm64 + smoke: true + - os: macos-15-intel + platform: darwin-x64 + smoke: true + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Set up pnpm + run: npm install --global pnpm@11.20.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build single executable + run: node scripts/build-sea.mjs ${{ matrix.platform }} + + - name: Smoke test binary + if: matrix.smoke + run: | + expected="$(node -p "require('./package.json').version")" + actual="$(dist/sea/zro-${{ matrix.platform }} --version)" + if [ "$actual" != "$expected" ]; then + echo "dist/sea/zro-${{ matrix.platform }} --version printed '$actual', expected '$expected'" >&2 + exit 1 + fi + + - name: Upload binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zro-${{ matrix.platform }} + path: dist/sea/zro-${{ matrix.platform }} + if-no-files-found: error + + release-binaries: + name: Attach binaries to the release + needs: binaries + # Attach whatever binaries built; one failed matrix leg must not leave the + # release empty. Skipped when publish failed, as no release exists to attach to. + if: ${{ !cancelled() && needs.binaries.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Download binaries + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: zro-* + path: dist/release + merge-multiple: true + + - name: Compress binaries + run: | + # ubuntu runners ship zstd; install only when missing to avoid an apt-get flake + command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y zstd; } + for binary in dist/release/zro-*; do + zstd -q -19 -f -k "$binary" + done + + - name: Write release manifests + run: >- + node scripts/release-manifest.mjs dist/release + ${{ needs.binaries.result != 'success' && '--allow-partial' || '' }} + + # Binaries and checksum manifests only. install.sh is served from the website. + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release upload "$tag" \ + dist/release/zro-* \ + dist/release/manifest.json \ + dist/release/manifest.zst.json \ + --clobber diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ea50a5d --- /dev/null +++ b/install.sh @@ -0,0 +1,330 @@ +#!/bin/bash + +set -euo pipefail + +# Parse command line arguments +TARGET="${1:-latest}" + +if [[ ! "$TARGET" =~ ^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$ ]]; then + echo "Usage: $0 [stable|latest|VERSION]" >&2 + exit 1 +fi + +# Refuse to run under sudo from a regular user's shell. This installer puts +# everything under $HOME, which under sudo typically resolves to root's home: +# the binary lands in /root/.local/bin (or is left root-owned in the user's +# home, depending on the distro's sudo configuration), and the 'zro' command +# is then not found in the user's own shell. Plain root with no sudo +# (containers, CI, root-only systems) is unaffected by this check. +if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && [ -z "${ZRO_INSTALL_ALLOW_SUDO:-}" ]; then + echo "Error: do not run this installer with sudo." >&2 + echo "" >&2 + echo "Zro installs into your home directory and does not need root access." >&2 + echo "With sudo, the installation would go into root's home directory instead of" >&2 + echo "yours, and the 'zro' command would not work from your own shell." >&2 + echo "" >&2 + echo "Please re-run the same command without sudo, e.g.:" >&2 + echo " curl -fsSL https://zro.moonmath.ai/install.sh | bash" >&2 + echo "" >&2 + echo "To intentionally install Zro for the root user, re-run with" >&2 + echo "ZRO_INSTALL_ALLOW_SUDO=1 set in the installer's environment, e.g.:" >&2 + echo " curl -fsSL https://zro.moonmath.ai/install.sh | sudo ZRO_INSTALL_ALLOW_SUDO=1 bash" >&2 + exit 1 +fi + +REPO="moonmath-ai/zro" +if [ "$TARGET" = "stable" ] || [ "$TARGET" = "latest" ]; then + DOWNLOAD_BASE_URL="https://github.com/${REPO}/releases/latest/download" +else + DOWNLOAD_BASE_URL="https://github.com/${REPO}/releases/download/v${TARGET}" +fi + +DOWNLOADER="" +if command -v curl >/dev/null 2>&1; then + DOWNLOADER="curl" +elif command -v wget >/dev/null 2>&1; then + DOWNLOADER="wget" +else + echo "Either curl or wget is required but neither is installed" >&2 + exit 1 +fi + +HAS_JQ=false +if command -v jq >/dev/null 2>&1; then + HAS_JQ=true +fi + +HAS_ZSTD=false +if command -v zstd >/dev/null 2>&1; then + HAS_ZSTD=true +fi + +download_file() { + local url="$1" + local output="${2:-}" + + if [ "$DOWNLOADER" = "curl" ]; then + if [ -n "$output" ]; then + curl -fsSL -o "$output" "$url" + else + curl -fsSL "$url" + fi + elif [ "$DOWNLOADER" = "wget" ]; then + if [ -n "$output" ]; then + wget -q -O "$output" "$url" + else + wget -q -O - "$url" + fi + else + return 1 + fi +} + +get_checksum_from_manifest() { + local json="$1" + local platform="$2" + + json=$(echo "$json" | tr -d '\n\r\t' | sed 's/ \+/ /g') + + if [[ $json =~ \"$platform\"[[:space:]]*:[[:space:]]*[{][^{}]*\"checksum\"[[:space:]]*:[[:space:]]*\"([a-f0-9]{64})\" ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + + return 1 +} + +get_size_from_manifest() { + local json="$1" + local platform="$2" + json=$(echo "$json" | tr -d '\n\r\t' | sed 's/ \+/ /g') + if [[ $json =~ \"$platform\"[[:space:]]*:[[:space:]]*[{][^{}]*\"size\"[[:space:]]*:[[:space:]]*([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +explain_exit() { + local install_code="$1" + if [ "$install_code" -lt 128 ]; then + return 0 + fi + if [ -t 0 ]; then + stty sane 2>/dev/null || true + fi + local red="" reset="" + if [ -t 2 ]; then + red=$'\033[31m' + reset=$'\033[0m' + fi + if [ "$install_code" -eq 137 ] && [ "$os" = "linux" ]; then + echo "${red}Installation was killed before it could finish (exit code 137). This usually means the system ran out of memory.${reset}" >&2 + echo "${red}Free up memory, then run this script again.${reset}" >&2 + else + echo "${red}Installation was killed before it could finish (exit code $install_code).${reset}" >&2 + fi +} + +ensure_path() { + local bin_dir="$1" + case ":${PATH}:" in + *":${bin_dir}:"*) return 0 ;; + esac + + local marker='export PATH="$HOME/.local/bin:$PATH"' + local rc + for rc in "${HOME}/.zshrc" "${HOME}/.bashrc"; do + if [ -f "$rc" ] && ! grep -q '.local/bin' "$rc"; then + printf '\n# Added by the zro installer\n%s\n' "$marker" >> "$rc" + fi + done + + echo "" + echo "Add ${bin_dir} to your PATH if zro is not found. Open a new terminal, or run:" + echo " ${marker}" +} + +main() { + case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) + echo "Windows is not supported by this script. Install with npm: npm install --global @moonmath-ai/zro" >&2 + exit 1 + ;; + *) + echo "Unsupported operating system: $(uname -s)" >&2 + exit 1 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + + if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then + arch="arm64" + fi + fi + + if [ "$os" = "linux" ]; then + if [ -f /lib/libc.musl-x86_64.so.1 ] || [ -f /lib/libc.musl-aarch64.so.1 ] || ldd /bin/ls 2>&1 | grep -q musl; then + platform="linux-${arch}-musl" + else + platform="linux-${arch}" + fi + else + platform="${os}-${arch}" + fi + + if [ "$platform" = "linux-arm64-musl" ]; then + echo "Zro has no Alpine arm64 binary. Node.js does not publish an official linux-arm64-musl build." >&2 + echo "Install with Node.js 22 or later: npm install --global @moonmath-ai/zro" >&2 + exit 1 + fi + + # Node.js musl builds link against musl libstdc++/libgcc, which base Alpine lacks. + if [ "$platform" = "linux-x64-musl" ]; then + for lib in libstdc++.so.6 libgcc_s.so.1; do + if [ ! -e "/lib/$lib" ] && [ ! -e "/usr/lib/$lib" ]; then + echo "Zro's musl build needs $lib, which is not installed." >&2 + echo "On Alpine, run as root: apk add libstdc++ libgcc" >&2 + echo "Then re-run this installer." >&2 + exit 1 + fi + done + fi + + if [ "$os" = "darwin" ]; then + command -v shasum >/dev/null 2>&1 || { echo "shasum is required" >&2; exit 1; } + else + command -v sha256sum >/dev/null 2>&1 || { echo "sha256sum is required" >&2; exit 1; } + fi + + checksum_matches() { + local actual + if [ "$os" = "darwin" ]; then + actual=$(shasum -a 256 "$1" | cut -d' ' -f1) + else + actual=$(sha256sum "$1" | cut -d' ' -f1) + fi + [ "$actual" = "$2" ] + } + + # Not local: the EXIT trap fires after main returns, when locals are gone. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + local manifest_json + if ! manifest_json=$(download_file "${DOWNLOAD_BASE_URL}/manifest.json"); then + echo "Failed to download the release manifest from ${DOWNLOAD_BASE_URL}/manifest.json" >&2 + exit 1 + fi + if [[ ! "$manifest_json" =~ ^[[:space:]]*\{ ]]; then + echo "Failed to get a valid release manifest (got unexpected content)." >&2 + exit 1 + fi + + local checksum size + if [ "$HAS_JQ" = true ]; then + checksum=$(echo "$manifest_json" | jq -r ".platforms[\"$platform\"].checksum // empty") + size=$(echo "$manifest_json" | jq -r ".platforms[\"$platform\"].size // empty") + else + checksum=$(get_checksum_from_manifest "$manifest_json" "$platform" || true) + size=$(get_size_from_manifest "$manifest_json" "$platform" || true) + fi + + if [ -z "$checksum" ] || [[ ! "$checksum" =~ ^[a-f0-9]{64}$ ]]; then + echo "Platform $platform not found in manifest" >&2 + exit 1 + fi + + local binary_path="${tmp}/zro-${platform}" + local verified=false + local zst_checksum="" + if [ "$HAS_ZSTD" = true ] && [[ "$size" =~ ^[1-9][0-9]*$ ]]; then + local zst_manifest_json + zst_manifest_json=$(download_file "${DOWNLOAD_BASE_URL}/manifest.zst.json" 2>/dev/null || true) + if [ "$HAS_JQ" = true ]; then + zst_checksum=$(echo "$zst_manifest_json" | jq -r ".platforms[\"$platform\"].checksum // empty" 2>/dev/null || true) + else + zst_checksum=$(get_checksum_from_manifest "$zst_manifest_json" "$platform" || true) + fi + fi + + if [[ "$zst_checksum" =~ ^[a-f0-9]{64}$ ]]; then + if download_file "${DOWNLOAD_BASE_URL}/zro-${platform}.zst" "${binary_path}.zst" \ + && checksum_matches "${binary_path}.zst" "$zst_checksum"; then + set +o pipefail + zstd -d -q -c "${binary_path}.zst" 2>/dev/null | head -c "$size" > "$binary_path" || true + set -o pipefail + if checksum_matches "$binary_path" "$checksum"; then + verified=true + fi + fi + rm -f "${binary_path}.zst" + fi + + if [ "$verified" = false ]; then + if ! download_file "${DOWNLOAD_BASE_URL}/zro-${platform}" "$binary_path"; then + echo "Download failed" >&2 + rm -f "$binary_path" + exit 1 + fi + if ! checksum_matches "$binary_path" "$checksum"; then + echo "Checksum verification failed" >&2 + rm -f "$binary_path" + exit 1 + fi + fi + + local bin_dir="${HOME}/.local/bin" + local dest="${bin_dir}/zro" + mkdir -p "$bin_dir" + cp "$binary_path" "$dest" + chmod +x "$dest" + if [ "$os" = "darwin" ]; then + xattr -d com.apple.quarantine "$dest" 2>/dev/null || true + fi + + local install_code=0 + local version_line="" + set +e + version_line=$("$dest" --version 2>"${tmp}/version.err") + install_code=$? + set -e + if [ "$install_code" -ne 0 ]; then + explain_exit "$install_code" + if [ "$install_code" -ge 128 ]; then + echo "The binary is at ${dest}. Run it again after freeing memory." >&2 + else + rm -f "$dest" + echo "The installed binary failed to start." >&2 + if [ -s "${tmp}/version.err" ]; then + cat "${tmp}/version.err" >&2 + fi + fi + exit "$install_code" + fi + + ensure_path "$bin_dir" + + echo "" + echo "Installation complete." + echo "" + echo " zro ${version_line}" + echo " ${dest}" + echo "" + echo "Next:" + echo " zro login" + echo " zro claude" + echo "" +} + +if [ -z "${ZRO_INSTALL_LIB:-}" ]; then + main "$@" +fi diff --git a/package.json b/package.json index aa5998e..b1beafd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "packageManager": "pnpm@11.20.0", "scripts": { "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", + "build:sea": "node scripts/build-sea.mjs", "prepack": "pnpm run build", "test": "vitest run", "test:clients": "node scripts/ci/check-clients.mjs", @@ -38,6 +39,8 @@ }, "devDependencies": { "@types/node": "^20.19.43", + "esbuild": "^0.28.2", + "postject": "1.0.0-alpha.6", "typescript": "^5.9.3", "vite": "^6.4.3", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23faee3..f37ac7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,12 @@ importers: '@types/node': specifier: ^20.19.43 version: 20.19.43 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -36,156 +42,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -391,6 +553,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -412,6 +578,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -473,6 +644,11 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + rollup@4.63.1: resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -612,81 +788,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@jridgewell/sourcemap-codec@1.6.0': {} '@napi-rs/lzma-linux-x64-gnu@1.5.1': @@ -836,6 +1090,8 @@ snapshots: check-error@2.1.3: {} + commander@9.5.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -873,6 +1129,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -914,6 +1199,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + rollup@4.63.1: dependencies: '@types/estree': 1.0.9 diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mjs new file mode 100644 index 0000000..4e6cf94 --- /dev/null +++ b/scripts/build-sea.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import esbuild from "esbuild"; +import { readPackageVersion, repoRoot, sha256File } from "./lib/common.mjs"; + +const require = createRequire(import.meta.url); +const { inject } = require("postject"); + +const NODE_VERSION = "22.23.5"; +const FUSE = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + +// Official Node.js tarballs from https://nodejs.org/dist/v22.23.5/SHASUMS256.txt. +// linux-arm64-musl is omitted: Node.js does not publish that build. +const NODE_DIST_SHA256 = { + "darwin-arm64": "d0c68c321161bb2c6483efea9a343010dea8e5e7f60d73c6a7eba140016d662f", + "darwin-x64": "d68bf234a07781141dfca61ae4461c24560f70bd53223d76a41529eb02e8554e", + "linux-arm64": "2babb9eb5264860d540849f4b064a6fbdebcdc323657d9d382ce23d313fd5e09", + "linux-x64": "cdeb8670a0c81f51b4dc4ac61919f9b1ad917b304bf8221eb939570663d240f8", + "linux-x64-musl": "aef6690916d4b4210b55618ea0beeb927d67184257879b1ebec6e309285f3c08", +}; + +export const PLATFORMS = { + "darwin-arm64": { nodeDist: "darwin-arm64" }, + "darwin-x64": { nodeDist: "darwin-x64" }, + "linux-arm64": { nodeDist: "linux-arm64" }, + "linux-x64": { nodeDist: "linux-x64" }, + "linux-x64-musl": { + nodeDist: "linux-x64-musl", + // The musl binary cannot run on a glibc builder. Generate the blob with the + // matching glibc Node and keep V8 code cache off; it is not portable across libc. + runnerDist: "linux-x64", + }, +}; + +function run(command, args) { + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited with ${result.status ?? "signal"}`); + } +} + +function download(url, dest, sha256) { + if (fs.existsSync(dest) && sha256File(dest) === sha256) return; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const partial = `${dest}.partial`; + run("curl", ["-fsSL", "-o", partial, url]); + const actual = sha256File(partial); + if (actual !== sha256) { + fs.rmSync(partial, { force: true }); + throw new Error(`Checksum mismatch for ${url}: ${actual}`); + } + fs.renameSync(partial, dest); +} + +function nodeBinary(distName, tarball, workDir) { + const folder = `node-v${NODE_VERSION}-${distName}`; + const binary = path.join(workDir, folder, "bin", "node"); + if (!fs.existsSync(binary)) { + fs.mkdirSync(workDir, { recursive: true }); + run("tar", ["-xf", tarball, "-C", workDir, `${folder}/bin/node`]); + } + fs.chmodSync(binary, 0o755); + return binary; +} + +async function bundle(version) { + const outfile = path.join(repoRoot, "dist", "sea", "zro.cjs"); + fs.mkdirSync(path.dirname(outfile), { recursive: true }); + await esbuild.build({ + entryPoints: [path.join(repoRoot, "src", "cli.ts")], + bundle: true, + platform: "node", + format: "cjs", + target: "node22", + outfile, + legalComments: "none", + define: { + __ZRO_VERSION__: JSON.stringify(version), + }, + plugins: [{ + name: "inline-version", + setup(build) { + // package.json is not inside the SEA blob; the version is inlined instead. + // Match the resolved module, not the importer, so every first-party + // import of version.js gets the stub, not just run.ts. + build.onResolve({ filter: /\/version\.js$/ }, (args) => { + if (args.resolveDir && path.resolve(args.resolveDir, args.path) === path.join(repoRoot, "src", "version.js")) { + return { path: path.join(repoRoot, "scripts", "sea-version-stub.js") }; + } + return null; + }); + }, + }], + logLevel: "warning", + }); + return outfile; +} + +async function buildPlatform(platform, bundlePath) { + const spec = PLATFORMS[platform]; + if (!spec) { + throw new Error(`Unknown platform "${platform}". Choose: ${Object.keys(PLATFORMS).join(", ")}.`); + } + const host = `${process.platform}-${process.arch}`; + const runnerDist = spec.runnerDist ?? spec.nodeDist; + if (host !== runnerDist) { + throw new Error(`Build ${platform} on ${runnerDist}. This machine is ${host}.`); + } + + const cacheDir = path.join(repoRoot, "dist", "sea", "cache"); + const workDir = path.join(repoRoot, "dist", "sea", "work", platform); + fs.mkdirSync(cacheDir, { recursive: true }); + + const targetArchive = path.join(cacheDir, `node-v${NODE_VERSION}-${spec.nodeDist}.tar.xz`); + download( + `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${spec.nodeDist}.tar.xz`, + targetArchive, + NODE_DIST_SHA256[spec.nodeDist], + ); + const targetNode = nodeBinary(spec.nodeDist, targetArchive, workDir); + + let runnerNode = targetNode; + if (spec.runnerDist) { + const runnerArchive = path.join(cacheDir, `node-v${NODE_VERSION}-${spec.runnerDist}.tar.xz`); + download( + `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${spec.runnerDist}.tar.xz`, + runnerArchive, + NODE_DIST_SHA256[spec.runnerDist], + ); + runnerNode = nodeBinary(spec.runnerDist, runnerArchive, path.join(repoRoot, "dist", "sea", "work", spec.runnerDist)); + } + + const versionCheck = spawnSync(runnerNode, ["--version"], { encoding: "utf8" }); + if (versionCheck.stdout?.trim() !== `v${NODE_VERSION}`) { + throw new Error(`Expected Node v${NODE_VERSION}, got ${versionCheck.stdout?.trim() || versionCheck.stderr}`); + } + + const blobPath = path.join(workDir, "sea-prep.blob"); + const configPath = path.join(workDir, "sea-config.json"); + fs.writeFileSync(configPath, JSON.stringify({ + main: bundlePath, + output: blobPath, + disableExperimentalSEAWarning: true, + useSnapshot: false, + // Code cache is produced by the runner binary and only loads in that same build. + useCodeCache: !spec.runnerDist, + })); + run(runnerNode, ["--experimental-sea-config", configPath]); + + const output = path.join(repoRoot, "dist", "sea", `zro-${platform}`); + fs.copyFileSync(targetNode, output); + fs.chmodSync(output, 0o755); + const darwin = spec.nodeDist.startsWith("darwin-"); + if (darwin) run("codesign", ["--remove-signature", output]); + await inject(output, "NODE_SEA_BLOB", fs.readFileSync(blobPath), { + sentinelFuse: FUSE, + overwrite: true, + ...(darwin ? { machoSegmentName: "NODE_SEA" } : {}), + }); + if (darwin) run("codesign", ["--sign", "-", output]); + console.log(output); +} + +async function main() { + const platforms = process.argv.slice(2).filter((arg) => arg !== "--"); + if (platforms.length === 0 || platforms.includes("--help") || platforms.includes("-h")) { + console.error(`Usage: node scripts/build-sea.mjs [platform...]`); + console.error(`Platforms: ${Object.keys(PLATFORMS).join(", ")}`); + process.exit(platforms.includes("--help") || platforms.includes("-h") ? 0 : 1); + } + const bundlePath = await bundle(readPackageVersion()); + for (const platform of platforms) { + await buildPlatform(platform, bundlePath); + } +} + +const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/scripts/lib/common.mjs b/scripts/lib/common.mjs new file mode 100644 index 0000000..83095c9 --- /dev/null +++ b/scripts/lib/common.mjs @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +// Single source of truth for release platforms; publish.yml's matrix must stay +// in step, and tests assert build-sea.mjs's PLATFORMS matches this list. +export const RELEASE_PLATFORMS = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linux-x64-musl", +]; + +export function sha256File(file) { + const hash = createHash("sha256"); + hash.update(fs.readFileSync(file)); + return hash.digest("hex"); +} + +export function readPackageVersion() { + return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version; +} diff --git a/scripts/release-manifest.mjs b/scripts/release-manifest.mjs new file mode 100644 index 0000000..386c492 --- /dev/null +++ b/scripts/release-manifest.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { RELEASE_PLATFORMS, readPackageVersion, sha256File } from "./lib/common.mjs"; + +export function writeReleaseManifests(directory, version, options = {}) { + const platforms = {}; + const zstPlatforms = {}; + for (const name of fs.readdirSync(directory)) { + const compressed = name.match(/^zro-(.+)\.zst$/); + if (compressed && RELEASE_PLATFORMS.includes(compressed[1])) { + zstPlatforms[compressed[1]] = { checksum: sha256File(path.join(directory, name)) }; + continue; + } + const plain = name.match(/^zro-(.+)$/); + if (plain && RELEASE_PLATFORMS.includes(plain[1])) { + const file = path.join(directory, name); + platforms[plain[1]] = { + checksum: sha256File(file), + size: fs.statSync(file).size, + }; + } + } + + const missing = RELEASE_PLATFORMS.filter( + (platform) => !platforms[platform] || !zstPlatforms[platform], + ); + if (options.requireAll && missing.length > 0) { + throw new Error( + `Missing ${missing.map((platform) => `zro-${platform} or zro-${platform}.zst`).join(", ")} in ${directory}`, + ); + } + if (!options.requireAll && Object.keys(platforms).length === 0) { + throw new Error(`No zro binaries found in ${directory}`); + } + + const ordered = (entries) => Object.fromEntries( + RELEASE_PLATFORMS.filter((platform) => entries[platform]).map((platform) => [platform, entries[platform]]), + ); + fs.writeFileSync( + path.join(directory, "manifest.json"), + `${JSON.stringify({ version, platforms: ordered(platforms) }, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(directory, "manifest.zst.json"), + `${JSON.stringify({ version, platforms: ordered(zstPlatforms) }, null, 2)}\n`, + ); + return { missingPlatforms: missing }; +} + +const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + const args = process.argv.slice(2); + const directory = args.find((arg) => !arg.startsWith("--")); + if (!directory) { + console.error("Usage: node scripts/release-manifest.mjs [--allow-partial]"); + process.exit(1); + } + try { + const { missingPlatforms } = writeReleaseManifests(directory, readPackageVersion(), { + requireAll: !args.includes("--allow-partial"), + }); + if (missingPlatforms.length > 0) { + console.warn(`No binaries for ${missingPlatforms.join(", ")}; attaching a partial release.`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/scripts/sea-version-stub.js b/scripts/sea-version-stub.js new file mode 100644 index 0000000..bacb070 --- /dev/null +++ b/scripts/sea-version-stub.js @@ -0,0 +1,3 @@ +export function readInstalledPackageVersion() { + throw new Error("Zro version is compiled into this binary."); +} diff --git a/src/run.ts b/src/run.ts index aea4a6a..c23d002 100644 --- a/src/run.ts +++ b/src/run.ts @@ -4,7 +4,6 @@ import { generateKeyPairSync, privateDecrypt, } from "node:crypto"; -import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -49,6 +48,7 @@ import { import type { CliRequest, RunIo } from "./types.js"; import { spawnCommand } from "./process.js"; import { banner, chooseConnectMethod, chooseInstall, chooseTool, helpText, isTty, modelName, promptLine, theme } from "./ui.js"; +import { readInstalledPackageVersion } from "./version.js"; const tools: Record = { claude: claudeTool, @@ -65,9 +65,10 @@ const tools: Record = { prime: primeTool }; -const PACKAGE_VERSION = ( - JSON.parse(fsSync.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string } -).version; +declare const __ZRO_VERSION__: string | undefined; + +// The single-executable build replaces __ZRO_VERSION__. The npm build reads package.json. +const PACKAGE_VERSION = typeof __ZRO_VERSION__ === "string" ? __ZRO_VERSION__ : readInstalledPackageVersion(); export async function run(argv: string[], io: RunIo = defaultIo()): Promise { let request: CliRequest; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..609297f --- /dev/null +++ b/src/version.ts @@ -0,0 +1,6 @@ +import fs from "node:fs"; + +export function readInstalledPackageVersion(): string { + const raw = fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"); + return (JSON.parse(raw) as { version: string }).version; +} diff --git a/tests/release-manifest.test.ts b/tests/release-manifest.test.ts new file mode 100644 index 0000000..29b9b17 --- /dev/null +++ b/tests/release-manifest.test.ts @@ -0,0 +1,115 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PLATFORMS } from "../scripts/build-sea.mjs"; +import { RELEASE_PLATFORMS } from "../scripts/lib/common.mjs"; +import { writeReleaseManifests } from "../scripts/release-manifest.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function sha256(contents: string): string { + return createHash("sha256").update(contents).digest("hex"); +} + +function makeReleaseDir(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "zro-manifest-")); + fs.writeFileSync(path.join(directory, "zro-linux-x64"), "hello"); + fs.writeFileSync(path.join(directory, "zro-linux-x64.zst"), "compressed"); + return directory; +} + +describe("release manifests", () => { + it("writes checksums and sizes for the platforms present", () => { + const directory = makeReleaseDir(); + const { missingPlatforms } = writeReleaseManifests(directory, "0.4.2"); + + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + expect(manifest).toEqual({ + version: "0.4.2", + platforms: { + "linux-x64": { checksum: sha256("hello"), size: 5 }, + }, + }); + + const zstManifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.zst.json"), "utf8")); + expect(zstManifest).toEqual({ + version: "0.4.2", + platforms: { + "linux-x64": { checksum: sha256("compressed") }, + }, + }); + + expect(missingPlatforms).toEqual(["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64-musl"]); + }); + + // install.sh only runs under bash on unix-like systems. + it.skipIf(process.platform === "win32")("the install script parses the manifest without jq", () => { + const directory = makeReleaseDir(); + writeReleaseManifests(directory, "0.4.2"); + + const parsed = spawnSync("bash", ["-c", [ + "set -euo pipefail", + "export ZRO_INSTALL_LIB=1", + `source ${JSON.stringify(path.join(root, "install.sh"))}`, + 'checksum=$(get_checksum_from_manifest "$MANIFEST" linux-x64)', + 'size=$(get_size_from_manifest "$MANIFEST" linux-x64)', + 'printf "%s %s\\n" "$checksum" "$size"', + ].join("\n")], { + encoding: "utf8", + env: { + ...process.env, + MANIFEST: fs.readFileSync(path.join(directory, "manifest.json"), "utf8"), + }, + }); + expect(parsed.status).toBe(0); + expect(parsed.stdout.trim()).toBe(`${sha256("hello")} 5`); + }); + + it("refuses a partial release set", () => { + const directory = makeReleaseDir(); + expect(() => writeReleaseManifests(directory, "0.4.2", { requireAll: true })) + .toThrow(/Missing zro-darwin-arm64/); + }); + + it("CLI refuses a partial set by default", () => { + const directory = makeReleaseDir(); + const result = spawnSync(process.execPath, [ + path.join(root, "scripts", "release-manifest.mjs"), + directory, + ], { encoding: "utf8" }); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/Missing zro-darwin-arm64/); + }); + + it("CLI allows a partial set when asked", () => { + const directory = makeReleaseDir(); + const result = spawnSync(process.execPath, [ + path.join(root, "scripts", "release-manifest.mjs"), + "--allow-partial", + directory, + ], { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stderr).toMatch(/No binaries for darwin-arm64/); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + expect(manifest.platforms).toEqual({ + "linux-x64": { checksum: sha256("hello"), size: 5 }, + }); + }); +}); + +describe("install.sh", () => { + it.skipIf(process.platform === "win32")("is valid bash", () => { + const result = spawnSync("bash", ["-n", path.join(root, "install.sh")], { encoding: "utf8" }); + expect(result.status).toBe(0); + }); +}); + +describe("release platforms", () => { + it("the build script and manifest generator share one platform list", () => { + expect(Object.keys(PLATFORMS)).toEqual(RELEASE_PLATFORMS); + }); +}); From af5227c61bb58c08881dfa8b7cf515bbadb1f170 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 09:22:44 +0000 Subject: [PATCH 2/7] update bin guard Signed-off-by: nonam3e --- .github/workflows/publish.yml | 2 ++ src/install.ts | 13 +++++++++++++ tests/install.test.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d85b809..c0f28ed 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,6 +115,8 @@ jobs: echo "dist/sea/zro-${{ matrix.platform }} --version printed '$actual', expected '$expected'" >&2 exit 1 fi + # Self-upgrade must refuse in the binary and point at the installer. + dist/sea/zro-${{ matrix.platform }} install --upgrade 2>&1 | grep -q "release binary" - name: Upload binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/src/install.ts b/src/install.ts index bf4a7c2..f20eb1c 100644 --- a/src/install.ts +++ b/src/install.ts @@ -8,6 +8,8 @@ import { spawnCommand } from "./process.js"; const ZRO_PACKAGE = "@moonmath-ai/zro"; +declare const __ZRO_VERSION__: string | undefined; + export interface InstallArgs { tool?: string; upgrade?: boolean; @@ -20,6 +22,17 @@ export async function install( env: NodeJS.ProcessEnv, ): Promise { if (!request.tool) { + // The single-executable build inlines __ZRO_VERSION__; the npm build leaves it undefined. + if (typeof __ZRO_VERSION__ === "string") { + io.stderr.write( + "This zro is a release binary; it does not update through npm.\n" + + "Update it by re-running the installer:\n" + + " curl -fsSL https://zro.moonmath.ai/install.sh | bash\n" + + "To install a specific version:\n" + + " curl -fsSL https://zro.moonmath.ai/install.sh | bash -s -- 0.4.2\n", + ); + return 1; + } return installNpm( `${ZRO_PACKAGE}@${request.version ?? "latest"}`, request.version ? `Installing Zro ${request.version}` : "Upgrading Zro", diff --git a/tests/install.test.ts b/tests/install.test.ts index 9f3a440..64d58d1 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -104,6 +104,22 @@ describe("zro install", () => { expect(self.calls[0].args.at(-1)).toBe("@moonmath-ai/zro@latest"); }); + it("refuses self-upgrade in release binaries and points at the installer", async () => { + const { spawn, calls } = recorder(); + const stderr = new PassThrough(); + const globals = globalThis as { __ZRO_VERSION__?: string }; + globals.__ZRO_VERSION__ = "0.4.2"; + try { + expect(await run(["install", "--upgrade"], io(spawn, new PassThrough(), stderr))).toBe(1); + } finally { + delete globals.__ZRO_VERSION__; + } + expect(calls).toEqual([]); + const message = await text(stderr); + expect(message).toContain("This zro is a release binary"); + expect(message).toContain("curl -fsSL https://zro.moonmath.ai/install.sh | bash"); + }); + it.each([ ["hermes", "hermes-agent.nousresearch.com/install.sh", "--skip-setup"], ])("uses the official installer for %s", async (tool, url, expectedArg) => { From 95e0d4b18ba764cca936b3dca2e269bd8dac00c6 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 09:58:53 +0000 Subject: [PATCH 3/7] self-update Signed-off-by: nonam3e --- .github/workflows/publish.yml | 21 ++++- scripts/build-sea.mjs | 7 +- src/install.ts | 30 +++++-- src/selfupdate.ts | 147 +++++++++++++++++++++++++++++++++ src/types.ts | 1 + tests/install.test.ts | 51 +++++++++++- tests/selfupdate.test.ts | 150 ++++++++++++++++++++++++++++++++++ 7 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 src/selfupdate.ts create mode 100644 tests/selfupdate.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c0f28ed..56de9d3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,8 +115,25 @@ jobs: echo "dist/sea/zro-${{ matrix.platform }} --version printed '$actual', expected '$expected'" >&2 exit 1 fi - # Self-upgrade must refuse in the binary and point at the installer. - dist/sea/zro-${{ matrix.platform }} install --upgrade 2>&1 | grep -q "release binary" + # Self-update must run in-binary (never fall back to npm), land on a known + # outcome (up to date, newer than latest, or installer guidance), and + # never mutate the release artifact. + checksum() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi + } + before="$(checksum dist/sea/zro-${{ matrix.platform }})" + output="$(dist/sea/zro-${{ matrix.platform }} install --upgrade 2>&1 || true)" + echo "$output" + case "$output" in + *"up to date"*|*"nothing to update"*|*"install.sh"*) ;; + *) echo "unexpected self-update output" >&2; exit 1 ;; + esac + after="$(checksum dist/sea/zro-${{ matrix.platform }})" + if [ "$before" != "$after" ]; then + echo "the self-update check mutated the release binary" >&2 + exit 1 + fi - name: Upload binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mjs index 4e6cf94..ade2194 100644 --- a/scripts/build-sea.mjs +++ b/scripts/build-sea.mjs @@ -68,7 +68,7 @@ function nodeBinary(distName, tarball, workDir) { return binary; } -async function bundle(version) { +async function bundle(version, platform) { const outfile = path.join(repoRoot, "dist", "sea", "zro.cjs"); fs.mkdirSync(path.dirname(outfile), { recursive: true }); await esbuild.build({ @@ -79,8 +79,10 @@ async function bundle(version) { target: "node22", outfile, legalComments: "none", + // Inlined so the executable knows its own version and release platform (self-update). define: { __ZRO_VERSION__: JSON.stringify(version), + __ZRO_PLATFORM__: JSON.stringify(platform), }, plugins: [{ name: "inline-version", @@ -173,9 +175,8 @@ async function main() { console.error(`Platforms: ${Object.keys(PLATFORMS).join(", ")}`); process.exit(platforms.includes("--help") || platforms.includes("-h") ? 0 : 1); } - const bundlePath = await bundle(readPackageVersion()); for (const platform of platforms) { - await buildPlatform(platform, bundlePath); + await buildPlatform(platform, await bundle(readPackageVersion(), platform)); } } diff --git a/src/install.ts b/src/install.ts index f20eb1c..ecec975 100644 --- a/src/install.ts +++ b/src/install.ts @@ -5,10 +5,12 @@ import { resolveTool, TOOLS, type ToolDescriptor } from "./catalog.js"; import type { SpawnProcess } from "./engine/types.js"; import type { RunIo } from "./types.js"; import { spawnCommand } from "./process.js"; +import { selfUpdate } from "./selfupdate.js"; const ZRO_PACKAGE = "@moonmath-ai/zro"; declare const __ZRO_VERSION__: string | undefined; +declare const __ZRO_PLATFORM__: string | undefined; export interface InstallArgs { tool?: string; @@ -22,16 +24,28 @@ export async function install( env: NodeJS.ProcessEnv, ): Promise { if (!request.tool) { - // The single-executable build inlines __ZRO_VERSION__; the npm build leaves it undefined. + // The single-executable build inlines these; the npm build leaves them undefined. if (typeof __ZRO_VERSION__ === "string") { - io.stderr.write( - "This zro is a release binary; it does not update through npm.\n" + - "Update it by re-running the installer:\n" + - " curl -fsSL https://zro.moonmath.ai/install.sh | bash\n" + - "To install a specific version:\n" + - " curl -fsSL https://zro.moonmath.ai/install.sh | bash -s -- 0.4.2\n", + if (request.version) { + io.stderr.write( + "Release binaries cannot install a specific version of zro.\n" + + "Run the installer with the version instead:\n" + + ` curl -fsSL https://zro.moonmath.ai/install.sh | bash -s -- ${request.version}\n`, + ); + return 1; + } + if (typeof __ZRO_PLATFORM__ !== "string") { + io.stderr.write("This zro build is missing its release platform and cannot self-update.\n"); + return 1; + } + return selfUpdate( + { + currentVersion: __ZRO_VERSION__, + platform: __ZRO_PLATFORM__, + execPath: io.execPath ?? process.execPath, + }, + io, ); - return 1; } return installNpm( `${ZRO_PACKAGE}@${request.version ?? "latest"}`, diff --git a/src/selfupdate.ts b/src/selfupdate.ts new file mode 100644 index 0000000..f3f3cca --- /dev/null +++ b/src/selfupdate.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { RunIo } from "./types.js"; + +const RELEASE_BASE = "https://github.com/moonmath-ai/zro/releases/latest/download"; + +export interface SelfUpdateArgs { + currentVersion: string; + platform: string; + execPath: string; +} + +interface ReleaseManifest { + version: string; + platforms: Record; +} + +export async function selfUpdate(args: SelfUpdateArgs, io: RunIo): Promise { + const fetcher = io.fetch ?? globalThis.fetch; + const guidance = installerGuidance(args.currentVersion); + let manifest: ReleaseManifest; + try { + const response = await fetcher(`${RELEASE_BASE}/manifest.json`, { + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`could not download the release manifest (HTTP ${response.status})`); + } + manifest = await response.json() as ReleaseManifest; + } catch (error) { + io.stderr.write(failure(error, guidance)); + return 1; + } + + const comparison = compareVersions(manifest.version, args.currentVersion); + if (comparison === 0) { + io.stdout.write(`zro ${args.currentVersion} is up to date.\n`); + return 0; + } + if (comparison < 0) { + io.stdout.write( + `zro ${args.currentVersion} is newer than the latest release (${manifest.version}); nothing to update.\n`, + ); + return 0; + } + + const entry = manifest.platforms[args.platform]; + if (!entry) { + io.stderr.write(`The latest release (${manifest.version}) has no ${args.platform} binary.\n`); + io.stderr.write("Install with npm instead: npm install --global @moonmath-ai/zro\n"); + return 1; + } + + let buffer: Buffer; + try { + const response = await fetcher(`${RELEASE_BASE}/zro-${args.platform}`, { + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`could not download the release binary (HTTP ${response.status})`); + } + buffer = Buffer.from(await response.arrayBuffer()); + } catch (error) { + io.stderr.write(failure(error, guidance)); + return 1; + } + + const checksum = createHash("sha256").update(buffer).digest("hex"); + if (buffer.byteLength !== entry.size || checksum !== entry.checksum) { + io.stderr.write(`The downloaded update for ${args.platform} failed verification.\n`); + io.stderr.write(guidance); + return 1; + } + + // Staged next to the target so the final rename stays on one filesystem and is + // atomic; a running process keeps its open inode until it exits. + const staging = path.join(path.dirname(args.execPath), `.zro-update-${process.pid}`); + try { + await fs.writeFile(staging, buffer); + // chmod rather than writeFile's mode option, which the umask can mask. + await fs.chmod(staging, 0o755); + await fs.rename(staging, args.execPath); + } catch (error) { + await fs.rm(staging, { force: true }); + io.stderr.write(failure(error, guidance)); + return 1; + } + io.stdout.write(`Updated zro from ${args.currentVersion} to ${manifest.version}.\n`); + return 0; +} + +function installerGuidance(version: string): string { + return ( + "Update it by re-running the installer:\n" + + " curl -fsSL https://zro.moonmath.ai/install.sh | bash\n" + + "To install a specific version:\n" + + ` curl -fsSL https://zro.moonmath.ai/install.sh | bash -s -- ${version}\n` + ); +} + +function failure(error: unknown, guidance: string): string { + const message = error instanceof Error ? error.message : String(error); + return `Could not update zro: ${message}.\n\n${guidance}`; +} + +function compareVersions(a: string, b: string): number { + const [aCore, aPre] = splitVersion(a); + const [bCore, bPre] = splitVersion(b); + for (let index = 0; index < 3; index += 1) { + if (aCore[index] !== bCore[index]) { + return (aCore[index] ?? 0) < (bCore[index] ?? 0) ? -1 : 1; + } + } + if (aPre === bPre) return 0; + if (aPre === undefined) return 1; + if (bPre === undefined) return -1; + return comparePrerelease(aPre, bPre); +} + +// Semver 11.4: numeric identifiers compare numerically and rank below +// alphanumeric ones; a shorter prerelease list ranks below a longer one. +function comparePrerelease(a: string, b: string): number { + const aParts = a.split("."); + const bParts = b.split("."); + for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) { + const aPart = aParts[index]; + const bPart = bParts[index]; + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + if (aPart === bPart) continue; + const aNumeric = /^\d+$/.test(aPart); + const bNumeric = /^\d+$/.test(bPart); + if (aNumeric && bNumeric) return Number(aPart) < Number(bPart) ? -1 : 1; + if (aNumeric) return -1; + if (bNumeric) return 1; + return aPart < bPart ? -1 : 1; + } + return 0; +} + +function splitVersion(version: string): [number[], string | undefined] { + const dash = version.indexOf("-"); + const core = (dash === -1 ? version : version.slice(0, dash)).split(".").map(Number); + const pre = dash === -1 ? undefined : version.slice(dash + 1); + return [core, pre]; +} diff --git a/src/types.ts b/src/types.ts index 420b557..8834816 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,7 @@ export interface RunIo { spawn?: SpawnProcess; platform?: NodeJS.Platform; version?: string; + execPath?: string; fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; openBrowser?: (url: string) => Promise; sleep?: (milliseconds: number) => Promise; diff --git a/tests/install.test.ts b/tests/install.test.ts index 64d58d1..e9aacc6 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; @@ -9,6 +10,8 @@ import { commandExists } from "../src/install.js"; import { run } from "../src/run.js"; import type { SpawnProcess } from "../src/types.js"; +const sha256Hex = (contents: string): string => createHash("sha256").update(contents).digest("hex"); + function recorder(exitCode = 0): { spawn: SpawnProcess; calls: Array<{ command: string; args: string[] }>; @@ -104,20 +107,60 @@ describe("zro install", () => { expect(self.calls[0].args.at(-1)).toBe("@moonmath-ai/zro@latest"); }); - it("refuses self-upgrade in release binaries and points at the installer", async () => { + it("updates itself from the release manifest in single-executable builds", async () => { + const { spawn, calls } = recorder(); + const stdout = new PassThrough(); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "zro-selfupdate-")); + const execPath = path.join(directory, "zro"); + await fs.writeFile(execPath, "old-binary"); + const globals = globalThis as { __ZRO_VERSION__?: string; __ZRO_PLATFORM__?: string }; + globals.__ZRO_VERSION__ = "0.4.2"; + globals.__ZRO_PLATFORM__ = "linux-x64"; + try { + const code = await run(["install", "--upgrade"], { + ...io(spawn, stdout), + execPath, + fetch: async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.endsWith("/manifest.json")) { + return Response.json({ + version: "0.5.0", + platforms: { "linux-x64": { checksum: sha256Hex("new-binary"), size: 10 } }, + }); + } + return new Response("new-binary"); + }, + }); + expect(code).toBe(0); + } finally { + delete globals.__ZRO_VERSION__; + delete globals.__ZRO_PLATFORM__; + } + expect(calls).toEqual([]); + expect(await fs.readFile(execPath, "utf8")).toBe("new-binary"); + expect(await text(stdout)).toContain("Updated zro from 0.4.2 to 0.5.0."); + }); + + it("refuses a pinned self-version in single-executable builds", async () => { const { spawn, calls } = recorder(); const stderr = new PassThrough(); const globals = globalThis as { __ZRO_VERSION__?: string }; globals.__ZRO_VERSION__ = "0.4.2"; try { - expect(await run(["install", "--upgrade"], io(spawn, new PassThrough(), stderr))).toBe(1); + const code = await run(["install", "--upgrade", "--version", "0.3.0"], { + ...io(spawn, new PassThrough(), stderr), + fetch: async () => { + throw new Error("unexpected fetch"); + }, + }); + expect(code).toBe(1); } finally { delete globals.__ZRO_VERSION__; } expect(calls).toEqual([]); const message = await text(stderr); - expect(message).toContain("This zro is a release binary"); - expect(message).toContain("curl -fsSL https://zro.moonmath.ai/install.sh | bash"); + expect(message).toContain("Release binaries cannot install a specific version"); + expect(message).toContain("bash -s -- 0.3.0"); }); it.each([ diff --git a/tests/selfupdate.test.ts b/tests/selfupdate.test.ts new file mode 100644 index 0000000..f9db670 --- /dev/null +++ b/tests/selfupdate.test.ts @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { selfUpdate } from "../src/selfupdate.js"; +import type { RunIo } from "../src/types.js"; + +function sha256(contents: string): string { + return createHash("sha256").update(contents).digest("hex"); +} + +function makeIo(fetch: NonNullable): { io: RunIo; stdout: PassThrough; stderr: PassThrough } { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + return { + io: { stdin: new PassThrough(), stdout, stderr, homeDir: os.tmpdir(), cwd: os.tmpdir(), fetch }, + stdout, + stderr, + }; +} + +function manifestFetcher(manifest: unknown, binary?: string): NonNullable { + return async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.endsWith("/manifest.json")) return Response.json(manifest); + if (binary !== undefined && url.endsWith("/zro-linux-x64")) return new Response(binary); + throw new Error(`unexpected fetch: ${url}`); + }; +} + +async function text(stream: PassThrough): Promise { + stream.end(); + let result = ""; + for await (const chunk of stream) result += chunk.toString(); + return result; +} + +async function makeExecPath(contents = "old-binary"): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "zro-selfupdate-")); + const execPath = path.join(directory, "zro"); + await fs.writeFile(execPath, contents); + return execPath; +} + +async function stagingLeftovers(execPath: string): Promise { + const names = await fs.readdir(path.dirname(execPath)); + return names.filter((name) => name.startsWith(".zro-update")); +} + +describe("selfUpdate", () => { + it("downloads, verifies, and atomically replaces the binary", async () => { + const execPath = await makeExecPath(); + const { io, stdout } = makeIo(manifestFetcher( + { version: "0.5.0", platforms: { "linux-x64": { checksum: sha256("new-binary"), size: 10 } } }, + "new-binary", + )); + + expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(await fs.readFile(execPath, "utf8")).toBe("new-binary"); + expect((await fs.stat(execPath)).mode & 0o111).toBe(0o111); + expect(await text(stdout)).toContain("Updated zro from 0.4.2 to 0.5.0."); + expect(await stagingLeftovers(execPath)).toEqual([]); + }); + + it("is a no-op when already up to date", async () => { + const execPath = await makeExecPath(); + const { io, stdout } = makeIo(manifestFetcher({ version: "0.4.2", platforms: {} })); + + expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(await text(stdout)).toContain("zro 0.4.2 is up to date."); + expect(await fs.readFile(execPath, "utf8")).toBe("old-binary"); + }); + + it("does not downgrade a binary newer than the latest release", async () => { + const execPath = await makeExecPath(); + const { io, stdout } = makeIo(manifestFetcher({ version: "0.4.2", platforms: {} })); + + expect(await selfUpdate({ currentVersion: "0.5.0", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(await text(stdout)).toContain("newer than the latest release (0.4.2)"); + expect(await fs.readFile(execPath, "utf8")).toBe("old-binary"); + }); + + it("updates across numeric prerelease iterations", async () => { + const execPath = await makeExecPath(); + const { io, stdout } = makeIo(manifestFetcher( + { version: "0.5.0-rc.10", platforms: { "linux-x64": { checksum: sha256("new-binary"), size: 10 } } }, + "new-binary", + )); + + expect(await selfUpdate({ currentVersion: "0.5.0-rc.2", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(await text(stdout)).toContain("Updated zro from 0.5.0-rc.2 to 0.5.0-rc.10."); + expect(await fs.readFile(execPath, "utf8")).toBe("new-binary"); + }); + + it("never downgrades to an older prerelease", async () => { + const execPath = await makeExecPath(); + const { io, stdout } = makeIo(manifestFetcher({ version: "0.5.0-rc.9", platforms: {} })); + + expect(await selfUpdate({ currentVersion: "0.5.0-rc.10", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(await text(stdout)).toContain("newer than the latest release (0.5.0-rc.9)"); + expect(await fs.readFile(execPath, "utf8")).toBe("old-binary"); + }); + + it("rejects a failed verification and keeps the old binary", async () => { + const execPath = await makeExecPath(); + const { io, stderr } = makeIo(manifestFetcher( + { version: "0.5.0", platforms: { "linux-x64": { checksum: sha256("something-else"), size: 10 } } }, + "new-binary", + )); + + expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(1); + + expect(await text(stderr)).toContain("failed verification"); + expect(await fs.readFile(execPath, "utf8")).toBe("old-binary"); + expect(await stagingLeftovers(execPath)).toEqual([]); + }); + + it("reports platforms missing from the manifest", async () => { + const execPath = await makeExecPath(); + const { io, stderr } = makeIo(manifestFetcher({ + version: "0.5.0", + platforms: { "darwin-arm64": { checksum: "0".repeat(64), size: 10 } }, + })); + + expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(1); + + const missing = await text(stderr); + expect(missing).toContain("no linux-x64 binary"); + expect(missing).toContain("npm install --global @moonmath-ai/zro"); + }); + + it("guides to the installer when the manifest cannot be fetched", async () => { + const execPath = await makeExecPath(); + const { io, stderr } = makeIo(async () => new Response("not found", { status: 404 })); + + expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(1); + + const guidance = await text(stderr); + expect(guidance).toContain("Could not update zro: could not download the release manifest (HTTP 404)"); + expect(guidance).toContain("curl -fsSL https://zro.moonmath.ai/install.sh | bash"); + expect(guidance).toContain("bash -s -- 0.4.2"); + }); +}); From 7120c5dafb4a1b321cb0932f832a074f8a18ee5c Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 10:54:34 +0000 Subject: [PATCH 4/7] self update Signed-off-by: nonam3e --- README.md | 5 +++-- src/args.ts | 1 + src/selfupdate.ts | 11 ++++++++--- src/ui.ts | 3 ++- tests/args.test.ts | 13 +++++++++++++ tests/install.test.ts | 7 +++++++ tests/selfupdate.test.ts | 15 +++++++++++++++ 7 files changed, 49 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3cbc62c..100b212 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,9 @@ zro status # connection, installed tools, last session, usage - **Requirements:** Node.js 22 or later on macOS, Linux, or Windows. - **Install:** `npm install --global @moonmath-ai/zro` (also `pnpm add --global @moonmath-ai/zro`). -- **Upgrade:** `zro install --upgrade` (upgrade zro); `zro install --upgrade` - or `zro install claude@2.1.105` / `zro install claude --version 2.1.105` for an agent. +- **Upgrade:** `zro update` or `zro install --upgrade` + `zro install --upgrade` or `zro install claude@2.1.105` / + `zro install claude --version 2.1.105` for an agent. - **Agent-assisted setup:** [SETUP_PROMPT.md](SETUP_PROMPT.md) has copy-paste prompts for end-user installs and local development checkouts. diff --git a/src/args.ts b/src/args.ts index 9765cc1..6e2814b 100644 --- a/src/args.ts +++ b/src/args.ts @@ -50,6 +50,7 @@ export function parseArgs(argv: string[]): CliRequest { return { command: "models", output: parsed.output, ...endpointOptions(parsed) }; } if (command === "install") return parseInstallArgs(argv.slice(1)); + if (command === "update") return parseInstallArgs(["--upgrade", ...argv.slice(1)]); if (command === "again") { const parsed = parseOptions(argv.slice(1), false); assertNoExtraArgs(parsed.extraArgs, command); diff --git a/src/selfupdate.ts b/src/selfupdate.ts index f3f3cca..308883c 100644 --- a/src/selfupdate.ts +++ b/src/selfupdate.ts @@ -3,7 +3,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { RunIo } from "./types.js"; -const RELEASE_BASE = "https://github.com/moonmath-ai/zro/releases/latest/download"; +const DEFAULT_RELEASE_BASE = "https://github.com/moonmath-ai/zro/releases/latest/download"; + +function releaseBase(env: NodeJS.ProcessEnv | undefined): string { + return env?.ZRO_UPDATE_BASE ?? DEFAULT_RELEASE_BASE; +} export interface SelfUpdateArgs { currentVersion: string; @@ -18,10 +22,11 @@ interface ReleaseManifest { export async function selfUpdate(args: SelfUpdateArgs, io: RunIo): Promise { const fetcher = io.fetch ?? globalThis.fetch; + const base = releaseBase(io.env); const guidance = installerGuidance(args.currentVersion); let manifest: ReleaseManifest; try { - const response = await fetcher(`${RELEASE_BASE}/manifest.json`, { + const response = await fetcher(`${base}/manifest.json`, { signal: AbortSignal.timeout(15_000), }); if (!response.ok) { @@ -54,7 +59,7 @@ export async function selfUpdate(args: SelfUpdateArgs, io: RunIo): Promise { expect(() => parseArgs(["claude", "--alias"])).toThrow("needs a value"); }); + it("maps update to a zro self-upgrade", () => { + expect(parseArgs(["update"])).toEqual({ + command: "install", + upgrade: true + }); + expect(parseArgs(["update", "--version", "0.3.0"])).toEqual({ + command: "install", + upgrade: true, + version: "0.3.0" + }); + expect(() => parseArgs(["update", "claude"])).toThrow("Unexpected argument for install: claude"); + }); + it("parses install, upgrade, and pinned harness versions", () => { expect(parseArgs(["install", "claude@2.1.105"])).toEqual({ command: "install", diff --git a/tests/install.test.ts b/tests/install.test.ts index e9aacc6..068a289 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -92,6 +92,13 @@ describe("zro install", () => { }); }); + it("updates zro itself through the update alias", async () => { + const self = recorder(); + + expect(await run(["update"], io(self.spawn))).toBe(0); + expect(self.calls[0].args.at(-1)).toBe("@moonmath-ai/zro@latest"); + }); + it("upgrades agents and Zro by reinstalling latest", async () => { const agent = recorder(); const self = recorder(); diff --git a/tests/selfupdate.test.ts b/tests/selfupdate.test.ts index f9db670..6e0b1cb 100644 --- a/tests/selfupdate.test.ts +++ b/tests/selfupdate.test.ts @@ -75,6 +75,21 @@ describe("selfUpdate", () => { expect(await fs.readFile(execPath, "utf8")).toBe("old-binary"); }); + it("honors ZRO_UPDATE_BASE as the release source", async () => { + const execPath = await makeExecPath(); + const urls: string[] = []; + const { io, stdout } = makeIo(async (input) => { + urls.push(String(input)); + return Response.json({ version: "0.4.4", platforms: {} }); + }); + io.env = { ZRO_UPDATE_BASE: "https://example.test/zro/releases/download/v9.9.9" }; + + expect(await selfUpdate({ currentVersion: "0.4.4", platform: "linux-x64", execPath }, io)).toBe(0); + + expect(urls).toEqual(["https://example.test/zro/releases/download/v9.9.9/manifest.json"]); + expect(await text(stdout)).toContain("zro 0.4.4 is up to date."); + }); + it("does not downgrade a binary newer than the latest release", async () => { const execPath = await makeExecPath(); const { io, stdout } = makeIo(manifestFetcher({ version: "0.4.2", platforms: {} })); From 71671af012d0495d21e830ade3b9f964f2b3492d Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 11:02:57 +0000 Subject: [PATCH 5/7] extra test Signed-off-by: nonam3e --- tests/install.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/install.test.ts b/tests/install.test.ts index 068a289..a29a236 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -94,9 +94,13 @@ describe("zro install", () => { it("updates zro itself through the update alias", async () => { const self = recorder(); + const pinned = recorder(); expect(await run(["update"], io(self.spawn))).toBe(0); expect(self.calls[0].args.at(-1)).toBe("@moonmath-ai/zro@latest"); + + expect(await run(["update", "--version", "0.3.0"], io(pinned.spawn))).toBe(0); + expect(pinned.calls[0].args.at(-1)).toBe("@moonmath-ai/zro@0.3.0"); }); it("upgrades agents and Zro by reinstalling latest", async () => { From 2ee73359b3fbf03cd218701a343f84f897820d46 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 11:09:38 +0000 Subject: [PATCH 6/7] win ignore in selfUpdate test Signed-off-by: nonam3e --- tests/selfupdate.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/selfupdate.test.ts b/tests/selfupdate.test.ts index 6e0b1cb..5fb328c 100644 --- a/tests/selfupdate.test.ts +++ b/tests/selfupdate.test.ts @@ -60,7 +60,10 @@ describe("selfUpdate", () => { expect(await selfUpdate({ currentVersion: "0.4.2", platform: "linux-x64", execPath }, io)).toBe(0); expect(await fs.readFile(execPath, "utf8")).toBe("new-binary"); - expect((await fs.stat(execPath)).mode & 0o111).toBe(0o111); + if (process.platform !== "win32") { + // Windows stat modes never carry the execute bits; only POSIX can assert them. + expect((await fs.stat(execPath)).mode & 0o111).toBe(0o111); + } expect(await text(stdout)).toContain("Updated zro from 0.4.2 to 0.5.0."); expect(await stagingLeftovers(execPath)).toEqual([]); }); From bb5d5fdf06579cec99d48ea6fbe2156d356c9926 Mon Sep 17 00:00:00 2001 From: nonam3e Date: Fri, 13 Nov 2026 11:19:03 +0000 Subject: [PATCH 7/7] lf convert Signed-off-by: nonam3e --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..01ee248 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Windows checkouts convert LF to CRLF under core.autocrlf, which breaks +# vite-node's import of the .mjs build scripts (and bash needs install.sh LF). +* text=auto eol=lf