diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..819c543 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,209 @@ +# Builds the prebuilt Colonizer apps and, for a version tag, publishes them as a GitHub release together +# with the one-command installer (scripts/install-release.sh, published as install.sh). docs/install.md +# tells people how to use them. +# +# - linux-binaries: colonizer-agentd and rtk for each colony architecture, and the harness for Linux, as +# static musl binaries built inside rust:1-alpine. That is `docker run` from an ordinary job, because +# GitHub's JavaScript actions don't run inside Alpine containers. +# - bundle: scripts/install.sh --bundle for each platform, then a smoke test: the app starts from the +# unpacked archive, finds its assets there, and serves its API and web UI. +# - release: tags only. The tag has to match the harness crate's version. +# +# A release contains no Anthropic code: the Claude Agent SDK and Claude Code are fetched where the app +# is installed (scripts/install.sh --bundle and scripts/install-release.sh say how). +name: Release + +on: + push: + tags: ["v*"] + pull_request: + paths: + - .github/workflows/release.yml + - scripts/install.sh + - scripts/install-release.sh + - scripts/record-fetch-at-install.mjs + - scripts/build-agentd.sh + - scripts/build-rtk.sh + - scripts/fetch-vendor.sh + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + linux-binaries: + strategy: + fail-fast: true + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Fetch rtk's pinned source + run: VENDOR_KINDS=source scripts/fetch-vendor.sh + + - name: Build inside rust:1-alpine + env: + ARCH: ${{ matrix.arch }} + run: | + docker run --rm -e ARCH -e COLONIZER_BUILD_HERE=1 -e OWNER="$(id -u):$(id -g)" \ + -v "$PWD:/src" -w /src rust:1-alpine sh -euc ' + apk add --no-cache musl-dev file >/dev/null + scripts/build-agentd.sh + scripts/build-rtk.sh + # The harness runs on the host, so only the Linux app needs a Linux build of it. + if [ "$ARCH" = x86_64 ]; then + cargo build --release --locked -p colonizer + install -m 755 target/release/colonizer dist/bin/colonizer + fi + chown -R "$OWNER" dist target + ' + mkdir -p prebuilt + cp dist/bin/colonizer-agentd dist/bin/rtk prebuilt/ + [ "$ARCH" != x86_64 ] || cp dist/bin/colonizer prebuilt/ + file prebuilt/* + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-binaries-${{ matrix.arch }} + path: prebuilt/ + if-no-files-found: error + retention-days: 7 + + bundle: + needs: linux-binaries + strategy: + fail-fast: true + matrix: + include: + - platform: linux-x86_64 + runner: ubuntu-24.04 + guest: x86_64 + - platform: darwin-arm64 + runner: macos-15 + guest: aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + PLATFORM: ${{ matrix.platform }} + VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('dev-{0}', github.sha) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Rust + if: matrix.platform == 'darwin-arm64' + run: rustup toolchain install stable --profile minimal && rustup default stable + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-binaries-${{ matrix.guest }} + path: prebuilt + + - name: Build the app + run: | + chmod 755 prebuilt/* + COLONIZER_PREBUILT="$PWD/prebuilt" scripts/install.sh --bundle + + - name: Package + run: | + printf '%s\n' "$VERSION" > dist/VERSION + cp LICENSE NOTICE dist/ + # Anthropic's code stays out: install-release.sh fetches it where the app is installed. + if find dist -path '*/node_modules/@anthropic-ai/claude-agent-sdk*' | grep -q .; then + echo "the Claude Agent SDK is in the bundle" >&2 + exit 1 + fi + mkdir -p stage out + cp -R dist stage/colonizer + tar -C stage -czf "out/colonizer-$PLATFORM.tar.gz" colonizer + ls -lh out + + - name: Smoke test + run: | + mkdir -p smoke/home + tar -xzf "out/colonizer-$PLATFORM.tar.gz" -C smoke + HOME="$PWD/smoke/home" COLONIZER_BIND=127.0.0.1:17878 COLONIZER_GATEWAY_BIND=127.0.0.1:17879 \ + smoke/colonizer/bin/colonizer > smoke/log 2>&1 & + pid=$! + for _ in $(seq 60); do + curl -fsS -o /dev/null http://127.0.0.1:17878/api/status && break + sleep 1 + done + cat smoke/log + grep -q "^assets: .*/smoke/colonizer$" smoke/log + curl -fsS http://127.0.0.1:17878/api/status | tee smoke/status.json + echo + # The app found its own parts: the in-VM daemon, the web UI and the Claude Code module. + grep -q '"agentd":true' smoke/status.json + grep -q '"web":true' smoke/status.json + grep -q '"claude-code"' smoke/status.json + curl -fsS http://127.0.0.1:17878/ | grep -qi '&2; exit 1; } + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: colonizer-* + path: release + merge-multiple: true + + - name: Publish + run: | + cp scripts/install-release.sh release/install.sh + cd release + sha256sum colonizer-*.tar.gz install.sh > SHA256SUMS + cat SHA256SUMS + { + echo "Prebuilt Colonizer for Linux x86_64 (with KVM) and Apple Silicon Macs, built from ${{ github.sha }}." + echo + echo '```sh' + echo 'curl -fsSL https://colonizer.dev/install.sh | sh' + echo '```' + echo + echo "The installer checks the archive against SHA256SUMS. It fetches the Claude Agent SDK from the npm registry, and on a Mac the Linux build of Claude Code, from Anthropic's own channels: neither is in the release. Guide: https://colonizer.dev/docs/install" + echo + echo '```' + cat SHA256SUMS + echo '```' + } > notes.md + gh release create "$TAG" colonizer-*.tar.gz install.sh SHA256SUMS \ + --repo "${{ github.repository }}" --target "${{ github.sha }}" \ + --title "Colonizer $TAG" --notes-file notes.md diff --git a/scripts/build-agentd.sh b/scripts/build-agentd.sh index 99e7f27..2ae918f 100755 --- a/scripts/build-agentd.sh +++ b/scripts/build-agentd.sh @@ -5,6 +5,9 @@ # # scripts/build-agentd.sh build # scripts/build-agentd.sh --smoke build, then run it inside a node:24-bookworm microVM +# +# COLONIZER_BUILD_HERE=1 builds in the current environment instead of a microVM. The release workflow +# sets it when it is already running inside rust:1-alpine, where no microVM can start. set -eu REPO=$(cd "$(dirname "$0")/.." && pwd) @@ -31,13 +34,18 @@ case "$(uname -m)" in arm64|aarch64) target="aarch64-unknown-linux-musl" ;; *) target="x86_64-unknown-linux-musl" ;; esac -echo "building colonizer-agentd ($target) in a rust:1-alpine microVM..." -"$MSB" run --no-tty -q -m 4G -c 8 \ - -v "$SRC:/src" \ - -v "$REPO/target/alpine:/build-target" \ - -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \ - -w /src \ - rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release -p colonizer-agentd --target-dir /build-target' +if [ "${COLONIZER_BUILD_HERE:-}" = 1 ]; then + echo "building colonizer-agentd ($target) here..." + (cd "$SRC" && cargo build --release -p colonizer-agentd --target-dir "$REPO/target/alpine") +else + echo "building colonizer-agentd ($target) in a rust:1-alpine microVM..." + "$MSB" run --no-tty -q -m 4G -c 8 \ + -v "$SRC:/src" \ + -v "$REPO/target/alpine:/build-target" \ + -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \ + -w /src \ + rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release -p colonizer-agentd --target-dir /build-target' +fi install -m 755 "$REPO/target/alpine/release/colonizer-agentd" "$OUT" file "$OUT" diff --git a/scripts/build-rtk.sh b/scripts/build-rtk.sh index bf28b35..e043b0a 100755 --- a/scripts/build-rtk.sh +++ b/scripts/build-rtk.sh @@ -8,6 +8,8 @@ # # scripts/build-rtk.sh build (skipped when dist/bin/rtk is already built from this source) # scripts/build-rtk.sh --smoke build, then run it inside a node:24-bookworm microVM +# +# COLONIZER_BUILD_HERE=1 builds in the current environment instead of a microVM, as build-agentd.sh does. set -eu REPO=$(cd "$(dirname "$0")/.." && pwd) @@ -31,15 +33,23 @@ if [ -x "$OUT" ] && [ -f "$stamp" ]; then echo "rtk $version ($target) already built" else SRC="$REPO/target/rtk-src" + # Built in place, the source has to sit outside this repository: under it, cargo would take rtk for a + # member of the harness workspace and refuse to build it. + [ "${COLONIZER_BUILD_HERE:-}" != 1 ] || SRC="${TMPDIR:-/tmp}/colonizer-rtk-src" rm -rf "$SRC" && mkdir -p "$SRC" "$REPO/target/rtk-build" "$REPO/target/alpine-rtk" "$REPO/target/alpine-cargo-registry" "$REPO/dist/bin" tar -xzf "$archive" -C "$SRC" --strip-components 1 - echo "building rtk $version ($target) in a rust:1-alpine microVM..." - "$MSB" run --no-tty -q -m 4G -c 8 \ - -v "$SRC:/src" \ - -v "$REPO/target/alpine-rtk:/build-target" \ - -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \ - -w /src \ - rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release --locked --target-dir /build-target' + if [ "${COLONIZER_BUILD_HERE:-}" = 1 ]; then + echo "building rtk $version ($target) here..." + (cd "$SRC" && cargo build --release --locked --target-dir "$REPO/target/alpine-rtk") + else + echo "building rtk $version ($target) in a rust:1-alpine microVM..." + "$MSB" run --no-tty -q -m 4G -c 8 \ + -v "$SRC:/src" \ + -v "$REPO/target/alpine-rtk:/build-target" \ + -v "$REPO/target/alpine-cargo-registry:/usr/local/cargo/registry" \ + -w /src \ + rust:1-alpine -- sh -c 'apk add --no-cache musl-dev >/dev/null && cargo build --release --locked --target-dir /build-target' + fi install -m 755 "$REPO/target/alpine-rtk/release/rtk" "$OUT" rm -f "$REPO/target/rtk-build/"* touch "$stamp" diff --git a/scripts/install-release.sh b/scripts/install-release.sh new file mode 100644 index 0000000..ef76d15 --- /dev/null +++ b/scripts/install-release.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# Installs a prebuilt Colonizer release. Published with every release as `install.sh`, and served at +# https://colonizer.dev/install.sh: +# +# curl -fsSL https://colonizer.dev/install.sh | sh +# curl -fsSL https://colonizer.dev/install.sh | sh -s -- --pull-image +# +# It downloads the app for this machine from the GitHub release, checks it against the release's +# SHA256SUMS, installs it to ~/.local/share/colonizer/app and links ~/.local/bin/colonizer. Running it +# again updates the app in place. Settings (~/.config/colonizer) and colonies (~/.local/share/colonizer) +# are never touched. +# +# Anthropic's code is not in a release, because it is not ours to redistribute, so two things come from +# Anthropic's own channels instead, each checked before it is used: +# - the Claude Agent SDK the agent module runs, from the npm registry, against the checksum the release +# recorded from package-lock.json (fetch-at-install); +# - on a Mac, the Linux build of Claude Code that colonies run, against Anthropic's manifest. A colony is +# a Linux microVM, so the Mac's own binary cannot run in it. +# +# COLONIZER_VERSION=v0.1.0 install that release instead of the latest +# COLONIZER_APP= install the app there instead of ~/.local/share/colonizer/app +# --pull-image also download the default colony image now (several gigabytes), so the +# first colony boots instead of waiting on it +# +# Everything is inside main(), so a download cut short by the network runs nothing. +set -eu + +main() { + repo="Colonizer-dev/harness" + docs="https://colonizer.dev/docs/install" + pull_image=0 + for arg in "$@"; do + case "$arg" in + --pull-image) pull_image=1 ;; + *) fail "unknown option: $arg" ;; + esac + done + + # One build per platform, and nothing pretends to work where it cannot. + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + platform=linux-x86_64 + { [ -r /dev/kvm ] && [ -w /dev/kvm ]; } || + fail "/dev/kvm is not readable and writable by $(id -un); colonies are KVM microVMs" + ;; + Darwin-arm64) platform=darwin-arm64 ;; + Darwin-x86_64) fail "Apple Silicon only: microsandbox's libkrun backend has no Intel Mac support" ;; + *) fail "no prebuilt Colonizer for $(uname -s) $(uname -m); see $docs" ;; + esac + + for c in curl tar; do need "$c"; done + command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || fail "missing sha256sum or shasum" + + version=${COLONIZER_VERSION:-latest} + if [ -n "${COLONIZER_RELEASE_URL:-}" ]; then + base=$COLONIZER_RELEASE_URL + elif [ "$version" = latest ]; then + base="https://github.com/$repo/releases/latest/download" + else + base="https://github.com/$repo/releases/download/$version" + fi + app=${COLONIZER_APP:-$HOME/.local/share/colonizer/app} + archive="colonizer-$platform.tar.gz" + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT INT TERM + + say "downloading Colonizer ($version, $platform)" + fetch "$base/$archive" "$tmp/$archive" + fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" + expected=$(awk -v f="$archive" '$2 == f || $2 == "*" f { print $1 }' "$tmp/SHA256SUMS") + [ -n "$expected" ] || fail "$archive is not listed in the release's SHA256SUMS" + [ "$(sha256_of "$tmp/$archive")" = "$expected" ] || fail "checksum mismatch for $archive; nothing was installed" + + mkdir -p "$tmp/unpack" + tar -xzf "$tmp/$archive" -C "$tmp/unpack" + [ -x "$tmp/unpack/colonizer/bin/colonizer" ] || fail "$archive has no colonizer/bin/colonizer" + installed=$(cat "$tmp/unpack/colonizer/VERSION" 2>/dev/null || echo "$version") + + for record in "$tmp"/unpack/colonizer/modules/agents/*/fetch-at-install; do + if [ -f "$record" ]; then fetch_at_install "$(dirname "$record")"; fi + done + if [ "$platform" = darwin-arm64 ]; then + guest_claude "$tmp/unpack/colonizer/bin/claude-guest" "$app/bin/claude-guest" + fi + + # Swap the whole directory, so a colonizer started mid-install finds either the old app or the new one. + mkdir -p "$(dirname "$app")" + rm -rf "$app.new" "$app.old" + mv "$tmp/unpack/colonizer" "$app.new" + [ ! -e "$app" ] || mv "$app" "$app.old" + mv "$app.new" "$app" + rm -rf "$app.old" + + mkdir -p "$HOME/.local/bin" + ln -sf "$app/bin/colonizer" "$HOME/.local/bin/colonizer" + say "installed Colonizer $installed to $app" + + if [ "$pull_image" = 1 ]; then + image=${COLONIZER_IMAGE:-node:24-bookworm} + say "pulling colony image $image" + "$app/vendor/microsandbox/bin/msb" pull "$image" + fi + + echo + missing="" + for c in git gh; do command -v "$c" >/dev/null 2>&1 || missing="$missing $c"; done + [ -z "$missing" ] || echo "Colonies also need:$missing (install them before launching one)." + if [ "$platform" = linux-x86_64 ] && ! command -v claude >/dev/null 2>&1; then + echo "Colonies run your native Claude Code install, and there is none on PATH: https://claude.com/claude-code" + fi + case ":$PATH:" in + *":$HOME/.local/bin:"*) run="colonizer" ;; + *) run="$HOME/.local/bin/colonizer" ;; + esac + echo "Run '$run' and open http://127.0.0.1:7878. If it was already running, restart it." + echo "Guide: $docs" +} + +say() { printf '==> %s\n' "$1"; } +fail() { printf 'colonizer install: %s\n' "$1" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"; } +fetch() { curl -fsSL --retry 3 -o "$2" "$1" || fail "could not download $1"; } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# Packages a module's release left out (scripts/record-fetch-at-install.mjs): each line of fetch-at-install +# is ` `, and the tarball's package/ directory becomes /. +fetch_at_install() { + module=$1 + while read -r path url sha; do + [ -n "$path" ] || continue + name=$(basename "$path") + say "downloading $name for the $(basename "$module") module" + fetch "$url" "$tmp/$name.tgz" + [ "$(sha256_of "$tmp/$name.tgz")" = "$sha" ] || fail "checksum mismatch for $url; nothing was installed" + rm -rf "$tmp/$name.unpack" && mkdir -p "$tmp/$name.unpack" + tar -xzf "$tmp/$name.tgz" -C "$tmp/$name.unpack" + [ -d "$tmp/$name.unpack/package" ] || fail "$url has no package/ directory" + mkdir -p "$(dirname "$module/$path")" + rm -rf "${module:?}/$path" + mv "$tmp/$name.unpack/package" "$module/$path" + done < "$module/fetch-at-install" +} + +# The Linux build of Claude Code for the guest, as scripts/fetch-agent-binary.sh fetches it for a source +# build: the `stable` channel, checked against the checksum in Anthropic's manifest. The copy from the +# previous install is reused when it is already that build. plutil reads the manifest, so a prebuilt +# install needs no Node.js. +guest_claude() { + out=$1 previous=$2 + cc="https://downloads.claude.ai/claude-code-releases" + cc_version=$(curl -fsSL --retry 3 "$cc/stable") || fail "could not read Claude Code's stable channel" + case "$cc_version" in [0-9]*) ;; *) fail "unexpected Claude Code version: $cc_version" ;; esac + fetch "$cc/$cc_version/manifest.json" "$tmp/claude-manifest.json" + cc_sha=$(plutil -extract "platforms.linux-arm64.checksum" raw -o - "$tmp/claude-manifest.json" 2>/dev/null) || + fail "no linux-arm64 checksum in the Claude Code $cc_version manifest" + if [ -f "$previous" ] && [ "$(sha256_of "$previous")" = "$cc_sha" ]; then + cp "$previous" "$out" + say "Claude Code $cc_version for colonies is already here" + else + say "downloading Claude Code $cc_version for colonies (linux-arm64)" + fetch "$cc/$cc_version/linux-arm64/claude" "$out" + [ "$(sha256_of "$out")" = "$cc_sha" ] || fail "checksum mismatch for Claude Code $cc_version; nothing was installed" + fi + chmod 755 "$out" +} + +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh index a9fdb32..410c6d7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,23 +6,35 @@ # link ~/.local/bin/colonizer # scripts/install.sh --pull-image also download the default colony image now, so the # first colony boots instead of waiting on a download +# scripts/install.sh --bundle build ./dist for a prebuilt release (.github/workflows/release.yml) +# +# A bundle is built on one machine and run on another, so --bundle skips the KVM check and the Claude Code +# fetch (scripts/install-release.sh fetches it where the app is installed). Binaries already in +# $COLONIZER_PREBUILT (colonizer, colonizer-agentd, rtk) are used as they are instead of being built: +# the release workflow builds the Linux ones as static musl binaries inside rust:1-alpine. set -eu root=$(cd "$(dirname "$0")/.." && pwd) dist="$root/dist" install_app=0 pull_image=0 +bundle=0 +prebuilt=${COLONIZER_PREBUILT:-} # Opt-in on purpose: the colony image is gigabytes, and an installer that # downloads that much without being asked is not a good guest on a laptop. for arg in "$@"; do case "$arg" in --install) install_app=1 ;; --pull-image) pull_image=1 ;; + --bundle) bundle=1 ;; *) echo "unknown option: $arg" >&2; exit 1 ;; esac done need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; } -for c in cargo npm node git gh curl tar; do need "$c"; done +# The build needs these; gh is for running the app, and a bundle is not run where it is built. +for c in npm node git curl tar; do need "$c"; done +[ "$bundle" = 1 ] || need gh +[ -n "$prebuilt" ] && [ -x "$prebuilt/colonizer" ] || need cargo # GNU calls it sha256sum, macOS ships shasum; either will do. command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || { echo "missing required command: sha256sum or shasum" >&2; exit 1; } @@ -30,7 +42,7 @@ command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || # One rule per platform, and nothing pretends to work where it cannot. case "$(uname -s)" in Linux) - [ -r /dev/kvm ] && [ -w /dev/kvm ] || { echo "/dev/kvm is not accessible; microVMs need KVM" >&2; exit 1; } + [ "$bundle" = 1 ] || { [ -r /dev/kvm ] && [ -w /dev/kvm ]; } || { echo "/dev/kvm is not accessible; microVMs need KVM" >&2; exit 1; } ;; Darwin) [ "$(uname -m)" = "arm64" ] || @@ -47,7 +59,7 @@ echo "==> vendored binaries (pinned, sha256-verified)" # A colony is a Linux microVM, so the agent binary mounted into it has to be a Linux one. On Linux that # is the host's own install; a Mac's is Mach-O and cannot run in the guest, so fetch the Linux build. -if [ "$(uname -s)" = "Darwin" ]; then +if [ "$(uname -s)" = "Darwin" ] && [ "$bundle" = 0 ]; then echo "==> Claude Code for the guest (Linux build, sha256-verified)" "$root/scripts/fetch-agent-binary.sh" fi @@ -61,11 +73,19 @@ for plugin in $(awk '$1 !~ /^#/ && $4 == "plugin" { print $1 }' "$root/vendor/ve [ -d "$dist/plugins/$plugin" ] || { echo "vendored plugin $plugin is missing from $dist/plugins after vendoring" >&2; exit 1; } done +# A prebuilt binary is used as it is; anything missing from $COLONIZER_PREBUILT is built here. +prebuilt_bin() { + [ -n "$prebuilt" ] && [ -x "$prebuilt/$1" ] || return 1 + mkdir -p "$dist/bin" + install -m 755 "$prebuilt/$1" "$dist/bin/$1" + echo "using prebuilt $1 from $prebuilt" +} + echo "==> colonizer-agentd (static musl build inside a microVM)" -MSB="$msb" "$root/scripts/build-agentd.sh" +prebuilt_bin colonizer-agentd || MSB="$msb" "$root/scripts/build-agentd.sh" echo "==> rtk (static musl build inside a microVM, for colonies that switch on compact command output)" -MSB="$msb" "$root/scripts/build-rtk.sh" +prebuilt_bin rtk || MSB="$msb" "$root/scripts/build-rtk.sh" echo "==> agent modules" mkdir -p "$dist/modules/agents" @@ -75,7 +95,14 @@ for module in "$root"/modules/agents/*/; do rm -rf "$target" mkdir -p "$target" (cd "$module" && tar --exclude=./node_modules --exclude=./test -cf - .) | tar -xf - -C "$target" - if [ -f "$target/package.json" ]; then + if [ -f "$target/package.json" ] && [ "$bundle" = 1 ]; then + # A release carries no Anthropic code. The Agent SDK is "all rights reserved", and its optional + # platform packages are Claude Code itself. Colonies run the Claude Code binary the install provides + # (pathToClaudeCodeExecutable in the runner), so the platform packages are left out, and the SDK is + # recorded in fetch-at-install for scripts/install-release.sh to fetch from the npm registry. + (cd "$target" && npm ci --omit=dev --omit=optional --no-audit --no-fund --silent) + (cd "$target" && node "$root/scripts/record-fetch-at-install.mjs" node_modules/@anthropic-ai/claude-agent-sdk) + elif [ -f "$target/package.json" ]; then (cd "$target" && npm ci --omit=dev --no-audit --no-fund --silent) fi echo "installed agent module $id" @@ -87,9 +114,11 @@ rm -rf "$dist/web" cp -r "$root/web/dist" "$dist/web" echo "==> harness" -cargo build --release -p colonizer --manifest-path "$root/Cargo.toml" -mkdir -p "$dist/bin" -install -m 755 "$root/target/release/colonizer" "$dist/bin/colonizer" +if ! prebuilt_bin colonizer; then + cargo build --release -p colonizer --manifest-path "$root/Cargo.toml" + mkdir -p "$dist/bin" + install -m 755 "$root/target/release/colonizer" "$dist/bin/colonizer" +fi if [ "$install_app" = 1 ]; then app="$HOME/.local/share/colonizer/app" diff --git a/scripts/record-fetch-at-install.mjs b/scripts/record-fetch-at-install.mjs new file mode 100644 index 0000000..9bf0204 --- /dev/null +++ b/scripts/record-fetch-at-install.mjs @@ -0,0 +1,28 @@ +// For a release bundle (scripts/install.sh --bundle): takes the named packages out of an installed module's +// node_modules and records where scripts/install-release.sh fetches them, so the release does not +// redistribute them. Run from the module directory: +// +// node record-fetch-at-install.mjs node_modules/@anthropic-ai/claude-agent-sdk ... +// +// Each line of fetch-at-install is ` `. The tarball is the one package-lock.json +// resolves, checked against the lockfile's integrity before its sha256 is written down, so the installer +// verifies exactly what npm would have installed. +import { createHash } from 'node:crypto'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; + +const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')); +const lines = []; +for (const path of process.argv.slice(2)) { + const entry = lock.packages?.[path]; + if (!entry?.resolved || !entry?.integrity) throw new Error(`${path} has no resolved tarball and integrity in package-lock.json`); + const [algorithm, expected] = entry.integrity.split(/-(.*)/s); + const response = await fetch(entry.resolved); + if (!response.ok) throw new Error(`${entry.resolved}: HTTP ${response.status}`); + const tarball = Buffer.from(await response.arrayBuffer()); + const actual = createHash(algorithm).update(tarball).digest('base64'); + if (actual !== expected) throw new Error(`${path}: the tarball does not match package-lock.json's integrity`); + lines.push(`${path} ${entry.resolved} ${createHash('sha256').update(tarball).digest('hex')}`); + rmSync(path, { recursive: true, force: true }); + console.log(`recorded ${path} ${entry.version} for install time`); +} +writeFileSync('fetch-at-install', `${lines.join('\n')}\n`);