diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml new file mode 100644 index 00000000..f7dce317 --- /dev/null +++ b/.github/workflows/package-smoke.yml @@ -0,0 +1,31 @@ +name: Package smoke + +on: + pull_request: + paths: + - '.tebako.yml' + - '.ruby-version' + - 'Gemfile*' + - 'exe/hive' + - 'bin/hive' + - 'lib/**' + - 'script/package' + - 'test/smoke/packaged_binary.sh' + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux-x86_64: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - name: Install Tebako build prerequisites + run: sudo apt-get update && sudo apt-get install --yes cmake build-essential + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4.2' + bundler-cache: true + - run: script/package linux x86_64 + - run: test/smoke/packaged_binary.sh dist/hive_0.1.0_linux_x86_64 0.1.0 diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..a7cb714b --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,20 @@ +name: Release smoke + +on: + workflow_call: + inputs: + artifact-name: + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-22.04 + steps: + - uses: actions/download-artifact@v7 + with: + name: ${{ inputs.artifact-name }} + - run: test/release/checksums.sh . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c0347ef8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + validate-tag: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - name: Validate immutable release tag + run: | + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "${GITHUB_REF_NAME#v}" = "$(ruby -ne 'puts $1 if /VERSION = "([^"]+)"/' lib/hive.rb)" + + test-and-security: + needs: validate-tag + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: '3.4.2', bundler-cache: true } + - run: bundle exec rake test + - run: bundle exec rubocop --parallel + - run: bundle exec brakeman --force --no-pager --quiet --ignore-config config/brakeman.ignore + - run: bundle exec bundler-audit check --update + + build-linux: + needs: validate-tag + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: '3.4.2', bundler-cache: true } + - run: sudo apt-get update && sudo apt-get install --yes cmake build-essential + - run: script/package linux x86_64 + - uses: actions/upload-artifact@v6 + with: { name: hive-linux, path: dist/hive_*_linux_x86_64 } + + build-macos: + needs: validate-tag + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: '3.4.2', bundler-cache: true } + - run: script/package darwin arm64 + - uses: actions/upload-artifact@v6 + with: { name: hive-macos, path: dist/hive_*_darwin_arm64 } + + tier1-acceptance: + needs: [build-linux, build-macos] + uses: ./.github/workflows/tier1-acceptance.yml + with: + version: ${{ github.ref_name }} + linux-artifact: hive-linux + macos-artifact: hive-macos + + publish: + needs: [test-and-security, build-linux, build-macos, tier1-acceptance] + runs-on: ubuntu-22.04 + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: { path: dist, merge-multiple: true } + - run: cp install.sh dist/install.sh + - run: script/release-manifest --dist dist --version "$GITHUB_REF_NAME" + - run: test/release/checksums.sh dist + - uses: actions/attest-build-provenance@v2 + with: { subject-path: 'dist/hive_*' } + - env: { GH_TOKEN: '${{ github.token }}' } + run: gh release create "$GITHUB_REF_NAME" dist/hive_* dist/install.sh dist/SHA256SUMS dist/release-manifest.json --generate-notes + + publish-downstream: + needs: publish + runs-on: ubuntu-22.04 + permissions: + contents: read + env: + GH_TOKEN: ${{ secrets.HIVE_DOWNSTREAM_TOKEN }} + AUR_SSH_KEY: ${{ secrets.HIVE_AUR_SSH_KEY }} + HOMEBREW_REPOSITORY: ${{ vars.HIVE_HOMEBREW_REPOSITORY }} + SKILLS_REPOSITORY: ${{ vars.HIVE_SKILLS_REPOSITORY }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: '3.4.2' } + - name: Download existing immutable release metadata + run: gh release download "$GITHUB_REF_NAME" --pattern release-manifest.json --dir dist + - name: Configure scoped AUR credential + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$AUR_SSH_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts + printf 'Host aur.archlinux.org\n IdentityFile ~/.ssh/aur\n IdentitiesOnly yes\n' >> ~/.ssh/config + - name: Clone downstream metadata repositories + run: | + gh repo clone "${HOMEBREW_REPOSITORY:-ivankuznetsov/homebrew-hive}" downstream/homebrew + gh repo clone "${SKILLS_REPOSITORY:-ivankuznetsov/hive-skills}" downstream/skills + git clone ssh://aur@aur.archlinux.org/hive-bin.git downstream/aur + - name: Generate deterministic metadata from the release manifest + run: | + script/update-homebrew-formula dist/release-manifest.json downstream/homebrew/Formula/hive.rb + cp packaging/aur/install-channel.yml downstream/aur/install-channel.yml + script/update-aur-pkgbuild dist/release-manifest.json downstream/aur/PKGBUILD downstream/aur/.SRCINFO + rsync -a --delete --exclude .git packaging/hive-skills/ downstream/skills/ + script/update-skills-metadata "$GITHUB_REF_NAME" downstream/skills + - name: Publish idempotent downstream commits + run: | + git config --global user.name hive-release-bot + git config --global user.email hive-release-bot@users.noreply.github.com + for repository in homebrew aur skills; do + if ! git -C "downstream/$repository" diff --quiet; then + git -C "downstream/$repository" add -A + git -C "downstream/$repository" commit -m "chore(release): publish ${GITHUB_REF_NAME}" + git -C "downstream/$repository" push + fi + done diff --git a/.github/workflows/tier1-acceptance.yml b/.github/workflows/tier1-acceptance.yml new file mode 100644 index 00000000..4fb1afad --- /dev/null +++ b/.github/workflows/tier1-acceptance.yml @@ -0,0 +1,61 @@ +name: Tier-1 acceptance + +on: + workflow_call: + inputs: + version: + required: true + type: string + linux-artifact: + required: true + type: string + macos-artifact: + required: true + type: string + +permissions: + contents: read + +jobs: + ubuntu-bash: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: { name: '${{ inputs.linux-artifact }}', path: dist } + - run: chmod +x dist/hive_*_linux_x86_64 + - run: test/acceptance/ubuntu_bash.sh dist/hive_*_linux_x86_64 "${{ inputs.version }}" + - run: test/acceptance/uninstall_preserves_work.sh + + macos-homebrew: + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: { name: '${{ inputs.macos-artifact }}', path: dist } + - run: chmod +x dist/hive_*_darwin_arm64 + - run: test/acceptance/macos_homebrew.sh dist/hive_*_darwin_arm64 "${{ inputs.version }}" + + arch-aur: + runs-on: ubuntu-22.04 + container: archlinux:base-devel + steps: + - run: pacman -Syu --noconfirm git ruby + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: { name: '${{ inputs.linux-artifact }}', path: dist } + - run: chmod +x dist/hive_*_linux_x86_64 + - run: test/acceptance/arch_aur.sh dist/hive_*_linux_x86_64 "${{ inputs.version }}" + + prompt-hosts: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + host: [claude, codex, pi] + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: { ruby-version: '3.4.2', bundler-cache: true } + - env: { HIVE_PROMPT_HOST: '${{ matrix.host }}' } + run: bundle exec ruby -Itest test/prompt/evaluate_install_prompt.rb diff --git a/.gitignore b/.gitignore index dc646373..32acd1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !/test/e2e/sample-project/vendor/cache/*.gem .byebug_history /coverage/ +/build/ /.qmd/ # Runtime registry of installed projects — machine-specific, not source. # See config.example.yml for the schema. diff --git a/.ruby-version b/.ruby-version index 2aa51319..4d9d11cf 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +3.4.2 diff --git a/.tebako.yml b/.tebako.yml new file mode 100644 index 00000000..3cf93c6d --- /dev/null +++ b/.tebako.yml @@ -0,0 +1,7 @@ +# Tebako is deliberately configured here rather than in CI so local and +# release builds use the same forward-portable Linux contract. The prefix is +# supplied through TEBAKO_PREFIX by script/package; never place its build cache +# inside the application root or it would be embedded in releases. +options: + patchelf: true +# Ruby: 3.4.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1f430d..80283b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added — self-contained distribution groundwork + +- Tebako-based macOS arm64 and Linux x86_64 release artifact contract, canonical release manifest, SHA-256 verification, and tag-gated GitHub Release workflow. +- XDG-owned installation/config/assets paths, channel receipts, and channel-aware lifecycle commands. Packaged releases no longer require a target-host Ruby runtime. + ### Added — opt-in interactive tmux brainstorm runtime - `brainstorm.runtime: tmux_interactive` runs stage `2-brainstorm` in a fresh per-task tmux session (`hive-2-brainstorm-`) with interactive `claude`, while preserving the existing `brainstorm.md` WAITING/COMPLETE marker contract. diff --git a/Gemfile b/Gemfile index 7a669b6e..ba1df0e6 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,10 @@ gem "telegram-bot-ruby", "~> 2.7" gem "bubbletea", "= 0.1.4" gem "lipgloss", "~> 0.2.2" +# Build-time only. Tebako bundles a CRuby runtime and the application into the +# release artifact; end users never install this gem or Ruby. +gem "tebako", "~> 0.14.0", require: false + group :development, :test do gem "minitest", "~> 6.0" gem "rake", "~> 13.0" diff --git a/Gemfile.lock b/Gemfile.lock index fd2ee152..0a85ea96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -121,6 +121,10 @@ GEM ruby-progressbar (1.13.0) securerandom (0.4.1) simpleidn (0.2.3) + tebako (0.14.0) + bundler + thor (~> 1.2) + yaml (~> 0.2.1) telegram-bot-ruby (2.7.0) dry-struct (~> 1.6) faraday (~> 2.0) @@ -133,6 +137,7 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.2.0) uri (1.1.1) + yaml (0.2.1) zeitwerk (2.7.5) PLATFORMS @@ -149,6 +154,7 @@ DEPENDENCIES rake (~> 13.0) rubocop (~> 1.86) rubocop-rails-omakase (~> 1.1) + tebako (~> 0.14.0) telegram-bot-ruby (~> 2.7) thor (~> 1.3) diff --git a/assets/agents/README.md b/assets/agents/README.md new file mode 100644 index 00000000..41bfa5f5 --- /dev/null +++ b/assets/agents/README.md @@ -0,0 +1,3 @@ +# Embedded agent assets + +Release-owned agent defaults are materialized under the XDG data directory. diff --git a/assets/project/.hive/README.md b/assets/project/.hive/README.md new file mode 100644 index 00000000..a5db59e6 --- /dev/null +++ b/assets/project/.hive/README.md @@ -0,0 +1,4 @@ +# Hive project files + +This directory contains the project-local, user-editable Hive scaffold. Hive +only updates files it originally generated and whose recorded hash is unchanged. diff --git a/assets/project/.hive/manifest.yml b/assets/project/.hive/manifest.yml new file mode 100644 index 00000000..34a120f6 --- /dev/null +++ b/assets/project/.hive/manifest.yml @@ -0,0 +1,2 @@ +schema_version: 1 +description: Hive-owned starter files; install-manifest.yml records their hashes. diff --git a/assets/project/.hive/workflow.yml b/assets/project/.hive/workflow.yml new file mode 100644 index 00000000..3f413dc7 --- /dev/null +++ b/assets/project/.hive/workflow.yml @@ -0,0 +1,2 @@ +schema_version: 1 +notes: Add project-specific Hive workflow guidance here. diff --git a/assets/prompts/README.md b/assets/prompts/README.md new file mode 100644 index 00000000..47ec626f --- /dev/null +++ b/assets/prompts/README.md @@ -0,0 +1,3 @@ +# Embedded prompt assets + +Release-owned prompt defaults are materialized under the XDG data directory. diff --git a/assets/services/dev.hive.daemon.plist.erb b/assets/services/dev.hive.daemon.plist.erb new file mode 100644 index 00000000..c3376159 --- /dev/null +++ b/assets/services/dev.hive.daemon.plist.erb @@ -0,0 +1 @@ + diff --git a/assets/services/hive.service.erb b/assets/services/hive.service.erb new file mode 100644 index 00000000..938d23d7 --- /dev/null +++ b/assets/services/hive.service.erb @@ -0,0 +1 @@ +# Rendered by Hive::Service::SystemdUser; retained as an embedded service asset. diff --git a/assets/stages/README.md b/assets/stages/README.md new file mode 100644 index 00000000..6f5f0c60 --- /dev/null +++ b/assets/stages/README.md @@ -0,0 +1,3 @@ +# Embedded stage assets + +Release-owned stage defaults are materialized under the XDG data directory. diff --git a/bin/hive b/bin/hive index bfd53884..51d7b9e2 100755 --- a/bin/hive +++ b/bin/hive @@ -4,11 +4,8 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "hive" require "hive/cli" - -if ARGV == [ "--version" ] || ARGV == [ "-v" ] - puts Hive::VERSION - exit 0 -end +require "hive/paths" +require "hive/install/receipt" # Thor only honours `--help` *before* the subcommand name (`hive help approve`); # `hive approve --help` would be consumed as the TARGET positional. Intercept @@ -27,9 +24,15 @@ def rewrite_help_flag!(argv) argv.unshift("help", cmd) end -rewrite_help_flag!(ARGV) - begin + Hive::Install::Receipt.ensure_native!(paths: Hive::Paths.new, program_name: $PROGRAM_NAME) + + if ARGV == [ "--version" ] || ARGV == [ "-v" ] + puts Hive::VERSION + exit 0 + end + + rewrite_help_flag!(ARGV) Hive::CLI.start(ARGV) rescue Hive::Error => e warn "hive: #{e.message}" diff --git a/docs/install-prompt.md b/docs/install-prompt.md new file mode 100644 index 00000000..4b9d53bd --- /dev/null +++ b/docs/install-prompt.md @@ -0,0 +1,42 @@ +# Host-neutral Hive installation prompt + +Paste this exact prompt into Claude Code, Codex, or Pi: + +```text +Install the stable Hive release for this machine, but do not change the host +until you show the selected channel and I approve it. + +First inspect OS, architecture, `command -v hive`, whether `hive --version` +looks like Hive (a bare MAJOR.MINOR.PATCH), package-manager availability, and +the current directory. Report missing git, bash, claude, gh, or jq as actionable +prerequisites; never install them or a package manager yourself. + +Choose exactly one channel: +- Clean macOS arm64 with Homebrew and no hive collision: `brew install + ivankuznetsov/hive/hive`. +- Clean Arch x86_64 with yay or paru and no owner of `/usr/bin/hive`: install + `hive-bin` through that helper. +- Ubuntu, any collision, or another supported fallback: run the official + checksum-verifying installer from the GitHub Release. If hive is owned by + another program, it must install only `hv` and leave hive untouched. + +After approval, execute the chosen command. Save the collision-safe executable +name as `HIVE_COMMAND` (`hive` or `hv`) and reuse it for every later Hive +invocation: `$HIVE_COMMAND --version`, `$HIVE_COMMAND init`, update, daemon, and +uninstall. Do not claim success until the version command prints a stable +version. Then ask whether to run `$HIVE_COMMAND init` in the current Git project; +do not start a daemon unless explicitly approved. + +Offer skills separately and only when the host supports its native mechanism: +- Claude Code: `claude plugin marketplace add ivankuznetsov/hive-skills`, then + `claude plugin install hive@hive-skills`. Remove it with + `claude plugin uninstall hive@hive-skills`. +- Codex: run `codex plugin marketplace add ivankuznetsov/hive-skills --ref + v0.1.0`, then `codex plugin add hive@hive-skills` and restart the session. + Remove it with `codex plugin remove hive@hive-skills`. +- Pi: install with `pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0` + and remove with + `pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`. +If the marketplace or policy prevents that action, finish the core install and +give one native follow-up command; never copy skill files manually. +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..6dfb66f2 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,20 @@ +# Install Hive + +Hive releases are self-contained binaries: you do not need system Ruby. + +On macOS arm64, Ubuntu 22.04+ x86_64, or Arch Linux x86_64, install the latest +stable release into `~/.local/bin`: + +```bash +curl -fsSL https://github.com/ivankuznetsov/hive/releases/latest/download/install.sh | bash +``` + +Set `HIVE_VERSION=v0.1.0` to pin an immutable stable release. The installer +downloads the release manifest and binary, verifies SHA-256 before moving it +into place, and writes an XDG state receipt. It never installs `git`, agent +CLIs, `gh`, or `jq`; it reports any missing tools with a next step instead. + +If another program owns `hive`, the installer leaves it untouched and installs +Hive as `hv`. It does not edit shell profiles; add `~/.local/bin` to your PATH +yourself if needed. Verify with `hive --version` (or `hv --version`), then run +`hive init` when you are ready to attach a project. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..080d4f83 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,15 @@ +# Distribution troubleshooting + +If `hive` is already owned by another program, use the `hv` command selected by +the installer. Do not unlink the other package. If an update or uninstall says +that `install.yml` is malformed, repair or remove only that receipt and rerun; +Hive intentionally refuses to guess a package manager. + +The installer reports missing `git`, `bash`, Claude Code, Codex/Pi, `gh`, or +`jq` but does not install them. Install required tools through your normal OS or +agent workflow, then repeat the affected command. + +For a user service that is registered but not running, inspect `hive daemon +status`, then use `hive init --start-daemon` only when you want it enabled. +Project `.hive` edits, XDG config/state, and legacy `~/.hive-state` are never +removed by default or `--purge`. diff --git a/exe/hive b/exe/hive new file mode 100755 index 00000000..592be90f --- /dev/null +++ b/exe/hive @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +# Stable Tebako entry point. Keeping this wrapper separate from bin/hive makes +# the packaged launch contract explicit while preserving the development CLI. +load File.expand_path("../bin/hive", __dir__) diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..fc9d2c6e --- /dev/null +++ b/install.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Hive's user-local, checksum-verifying installer. It intentionally diagnoses +# missing prerequisites rather than invoking a system package manager. +set -euo pipefail + +repository=${HIVE_REPOSITORY:-ivankuznetsov/hive} +api_base="https://api.github.com/repos/$repository" +release_override=${HIVE_RELEASE_BASE_URL:-} +bin_dir=${HIVE_BIN_DIR:-"$HOME/.local/bin"} +state_home=${XDG_STATE_HOME:-"$HOME/.local/state"} +receipt="$state_home/hive/install.yml" + +die() { printf 'hive installer: %s\n' "$*" >&2; exit 1; } +warn() { printf 'hive installer: warning: %s\n' "$*" >&2; } + +command -v curl >/dev/null 2>&1 || die "curl is required; install curl and rerun" +if command -v sha256sum >/dev/null 2>&1; then + checksum() { printf '%s %s\n' "$1" "$2" | sha256sum --check --status -; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { printf '%s %s\n' "$1" "$2" | shasum -a 256 --check --status -; } +else + die "a SHA-256 tool (sha256sum or shasum) is required; install one and rerun" +fi + +if [[ ${1:-} == --uninstall ]]; then + [[ -f $receipt ]] || die "no installation receipt at $receipt; remove the executable manually" + executable=$(awk -F': ' '$1 == "executable_path" { print $2; exit }' "$receipt") + command_name=$(awk -F': ' '$1 == "command_name" { print $2; exit }' "$receipt") + [[ -n $executable && -n $command_name ]] || die "receipt is malformed; repair it manually" + helper=$(mktemp "${TMPDIR:-/tmp}/hive-uninstall.XXXXXX") + printf '#!/usr/bin/env bash\nsleep 1\nrm -f -- %q\n' "$executable" > "$helper" + if [[ $command_name == hive && -L "$(dirname "$executable")/hv" ]] && [[ $(readlink "$(dirname "$executable")/hv") == hive ]]; then + printf 'rm -f -- %q\n' "$(dirname "$executable")/hv" >> "$helper" + fi + printf 'rm -f -- %q\nrm -f -- %q\n' "$receipt" "$helper" >> "$helper" + chmod 700 "$helper" + nohup bash "$helper" >/dev/null 2>&1 & + printf 'hive installer: scheduled post-exit removal for %s\n' "$executable" + exit 0 +fi + +version=${HIVE_VERSION:-} +if [[ -z $version ]]; then + latest=$(curl -fsSL "$api_base/releases/latest") || die "could not determine the latest stable release" + version=$(printf '%s\n' "$latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) +fi +[[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "HIVE_VERSION must be a stable vMAJOR.MINOR.PATCH tag" + +case $(uname -s) in + Darwin) + [[ $(uname -m) == arm64 || $(uname -m) == aarch64 ]] || die "unsupported macOS architecture $(uname -m); Hive v1 requires arm64" + target="hive_${version#v}_darwin_arm64" + ;; + Linux) + [[ $(uname -m) == x86_64 || $(uname -m) == amd64 ]] || die "unsupported Linux architecture $(uname -m); Hive v1 requires x86_64" + target="hive_${version#v}_linux_x86_64" + ;; + *) die "unsupported operating system $(uname -s); Hive v1 supports macOS arm64 and Linux x86_64" ;; +esac + +release_base=${release_override:-"https://github.com/$repository/releases/download/$version"} +receipt_release_base="https://github.com/ivankuznetsov/hive/releases/download/$version" +mkdir -p "$bin_dir" "$(dirname "$receipt")" || die "cannot create the user-local install directories" + +is_hive() { + local output + output=$("$1" --version 2>/dev/null || true) + [[ $output =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +has_foreign_collision() { + local command_name=$1 destination=$2 path_command + path_command=$(command -v "$command_name" 2>/dev/null || true) + { [[ -n $path_command ]] && ! is_hive "$path_command"; } || + { [[ -e $destination || -L $destination ]] && ! is_hive "$destination"; } +} + +destination="$bin_dir/hive" +command_name=hive +if has_foreign_collision hive "$destination"; then + destination="$bin_dir/hv" + command_name=hv + if has_foreign_collision hv "$destination"; then + die "hive and hv collide with other programs on PATH; choose HIVE_BIN_DIR and retry" + fi +fi + +if [[ -x $destination ]] && [[ $("$destination" --version 2>/dev/null || true) == "${version#v}" ]] && [[ -f $receipt ]]; then + printf 'hive installer: %s is already installed at %s\n' "$version" "$destination" + exit 0 +fi +if [[ -e $receipt || -L $receipt ]]; then + [[ -f $receipt && ! -L $receipt ]] || die "receipt path $receipt is not a regular file; repair it manually before retrying" +fi + +install_lock="$receipt.lock" +temporary= +transaction_active=0 +destination_replaced=0 +alias_replaced=0 +receipt_replaced=0 +destination_backup= +alias_backup= +receipt_backup= +destination_stage= +receipt_tmp= +cleanup() { + if [[ $transaction_active == 1 ]]; then + [[ $destination_replaced == 1 ]] && rm -f -- "$destination" + [[ -n $destination_backup && -e $destination_backup ]] && mv -f -- "$destination_backup" "$destination" + if [[ $command_name == hive ]]; then + [[ $alias_replaced == 1 ]] && rm -f -- "$bin_dir/hv" + [[ -n $alias_backup && ( -e $alias_backup || -L $alias_backup ) ]] && mv -f -- "$alias_backup" "$bin_dir/hv" + fi + [[ $receipt_replaced == 1 ]] && rm -f -- "$receipt" + [[ -n $receipt_backup && -e $receipt_backup ]] && mv -f -- "$receipt_backup" "$receipt" + fi + [[ -n $destination_stage ]] && rm -f -- "$destination_stage" + [[ -n $receipt_tmp ]] && rm -f -- "$receipt_tmp" + [[ -n $temporary ]] && rm -rf -- "$temporary" + rm -rf -- "$install_lock" +} +mkdir "$install_lock" 2>/dev/null || die "another Hive install is active (lock: $install_lock); wait for it to finish and retry" +trap cleanup EXIT +temporary=$(mktemp -d "${TMPDIR:-/tmp}/hive-install.XXXXXX") +manifest="$temporary/release-manifest.json" +curl -fsSL "$release_base/release-manifest.json" -o "$manifest" || die "could not download release manifest" +expected=$(awk -v target="$target" ' + index($0, target) { wanted=1 } + wanted && /"sha256"/ { line=$0; sub(/^.*"sha256"[[:space:]]*:[[:space:]]*"/, "", line); sub(/".*$/, "", line); print line; exit } +' "$manifest") +[[ $expected =~ ^[a-f0-9]{64}$ ]] || die "release manifest has no valid checksum for $target" +binary="$temporary/$target" +curl -fsSL "$release_base/$target" -o "$binary" || die "could not download $target" +checksum "$expected" "$binary" || die "checksum mismatch for $target; no files were installed" + +for tool in git bash claude gh jq; do + command -v "$tool" >/dev/null 2>&1 || warn "$tool is not installed; install it yourself before using the command that needs it" +done +umask 077 +destination_stage="$bin_dir/.${command_name}.new.$$" +mv -f -- "$binary" "$destination_stage" +chmod 755 "$destination_stage" +receipt_tmp="$receipt.tmp.$$" +cat > "$receipt_tmp" </dev/null || true) + if [[ -n $path_hv ]] && ! is_hive "$path_hv"; then + warn "preserving unrelated hv command at $path_hv; no hv alias was created" + elif [[ -e $bin_dir/hv || -L $bin_dir/hv ]]; then + if is_hive "$bin_dir/hv"; then + alias_backup="$bin_dir/.hv.old.$$" + mv -f -- "$bin_dir/hv" "$alias_backup" + ln -s hive "$bin_dir/hv" + alias_replaced=1 + else + warn "preserving unrelated hv command at $bin_dir/hv; no hv alias was created" + fi + else + ln -s hive "$bin_dir/hv" + alias_replaced=1 + fi +fi +mv -f -- "$receipt_tmp" "$receipt" +receipt_replaced=1 +receipt_tmp= +rm -f -- "$destination_backup" "$alias_backup" "$receipt_backup" +transaction_active=0 +printf 'hive installer: installed %s as %s at %s\n' "$version" "$command_name" "$destination" +printf 'hive installer: add %s to PATH if it is not already present (no shell profile was changed)\n' "$bin_dir" diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb new file mode 100644 index 00000000..4cffd4f9 --- /dev/null +++ b/lib/hive/assets.rb @@ -0,0 +1,116 @@ +require "digest" +require "fileutils" +require "securerandom" +require "yaml" + +module Hive + # Materializes immutable embedded assets to writable XDG storage and only + # refreshes project files Hive still owns. Nothing in here writes into the + # packaged image, which is read-only under Tebako. + class Assets + Report = Data.define(:generated, :preserved) + + class << self + def hydrate!(paths:, project_root:, version: Hive::VERSION) + materialize_release!(paths, version) + scaffold_project!(project_root, version) + end + + def materialize_release!(paths, version) + destination = File.join(paths.releases, version) + unless File.directory?(destination) + begin + FileUtils.mkdir_p(paths.releases, mode: 0o700) + temporary = "#{destination}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}" + FileUtils.cp_r("#{root}/.", temporary) + File.rename(temporary, destination) + ensure + FileUtils.rm_rf(temporary) if defined?(temporary) && temporary && File.exist?(temporary) + end + end + + FileUtils.mkdir_p(File.dirname(paths.current_assets), mode: 0o700) + current_tmp = "#{paths.current_assets}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}" + File.symlink(destination, current_tmp) + File.rename(current_tmp, paths.current_assets) + destination + end + + def scaffold_project!(project_root, version) + destination = File.join(project_root, ".hive") + FileUtils.mkdir_p(destination, mode: 0o700) + previous = load_manifest(File.join(destination, "install-manifest.yml")) + owned = previous.fetch("files", {}) + generated = [] + preserved = [] + + project_sources.each do |relative, source| + target = File.join(destination, relative) + source_hash = digest(source) + if !File.exist?(target) + write_atomically(target, File.binread(source)) + generated << relative + owned[relative] = source_hash + else + target_hash = digest(target) + if target_hash == source_hash + # A previous process may have completed the atomic file rename + # but been interrupted before the final manifest write. Exact + # embedded bytes are sufficient proof to recover ownership. + generated << relative + owned[relative] = source_hash + elsif owned[relative] == target_hash + write_atomically(target, File.binread(source)) + generated << relative + owned[relative] = source_hash + else + preserved << relative + end + end + end + + manifest = { "schema_version" => 1, "version" => version, "files" => owned } + write_atomically(File.join(destination, "install-manifest.yml"), YAML.dump(manifest)) + Report.new(generated, preserved) + end + + private + + def root + File.expand_path("../../assets", __dir__) + end + + def project_sources + Dir.glob(File.join(root, "project", ".hive", "**", "*"), File::FNM_DOTMATCH).filter_map do |source| + next unless File.file?(source) + + [ source.delete_prefix(File.join(root, "project", ".hive", "")), source ] + end + end + + def load_manifest(path) + return { "files" => {} } unless File.file?(path) + + parsed = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) + return { "files" => {} } unless parsed.is_a?(Hash) && parsed["files"].is_a?(Hash) + + parsed + rescue Psych::Exception + { "files" => {} } + end + + def digest(path) + Digest::SHA256.file(path).hexdigest + end + + def write_atomically(path, content) + FileUtils.mkdir_p(File.dirname(path), mode: 0o700) + temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}" + File.binwrite(temporary, content) + File.rename(temporary, path) + ensure + File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary) + end + end + end +end diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index 9bc60cb8..34672575 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -63,9 +63,32 @@ module Hive See `wiki/commands/init.md` for the full prompt flow and ADR-023. DESC option :force, type: :boolean, default: false, desc: "skip clean-tree check" + option :provider, type: :string, default: "anthropic", desc: "provider reference stored in XDG user config" + option :model, type: :string, default: "claude-sonnet", desc: "model reference stored in XDG user config" + option :credential_env, type: :string, default: "ANTHROPIC_API_KEY", desc: "environment variable holding provider credentials" + option :start_daemon, type: :boolean, default: false, desc: "enable and start the registered per-user daemon service" def init(project_path = Dir.pwd) require "hive/commands/init" - Hive::Commands::Init.new(project_path, force: options[:force]).call + Hive::Commands::Init.new( + project_path, force: options[:force], provider: options[:provider], + model: options[:model], credential_env: options[:credential_env], start_service: options[:start_daemon] + ).call + end + + desc "update", "Update Hive through the channel recorded at install time" + def update + require "hive/paths" + require "hive/commands/update" + Hive::Commands::Update.new(paths: Hive::Paths.new).call + end + + desc "uninstall [PROJECT_PATH]", "Remove Hive through its install channel while preserving user work" + option :purge, type: :boolean, default: false, + desc: "remove only unchanged Hive-generated files without prompting" + def uninstall(project_path = Dir.pwd) + require "hive/paths" + require "hive/commands/uninstall" + Hive::Commands::Uninstall.new(paths: Hive::Paths.new, project_root: project_path, purge: options[:purge]).call end desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)" diff --git a/lib/hive/command_name.rb b/lib/hive/command_name.rb new file mode 100644 index 00000000..c45b413a --- /dev/null +++ b/lib/hive/command_name.rb @@ -0,0 +1,27 @@ +module Hive + # The executable can be exposed as hive or hv. The latter is required when a + # different program already owns hive; keeping argv-zero here makes all + # messages and service definitions use the command the user actually chose. + class CommandName + class Invalid < Hive::InvalidTaskPath; end + + ALIASES = %w[hive hv].freeze + + attr_reader :value + + def self.from(argv_zero = $PROGRAM_NAME) + value = File.basename(argv_zero.to_s) + raise Invalid, "unsupported Hive command name #{value.inspect}; expected hive or hv" unless ALIASES.include?(value) + + new(value) + end + + def initialize(value) + @value = value + end + + def usage(*args) + ([value] + args).join(" ") + end + end +end diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index f88bca6f..a638f47e 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -10,6 +10,10 @@ require "hive/daemon/child_supervisor" require "hive/daemon/status_consumer" require "hive/daemon/pr_merge_watcher" require "hive/daemon/logger" +require "hive/paths" +require "hive/platform" +require "hive/install/receipt" +require "hive/service/manager" module Hive module Commands @@ -44,7 +48,8 @@ module Hive end def initialize(subcommand, target = nil, detach: false, dry_run: false, - all: false, json: false, hive_home: Hive::Config.hive_home) + all: false, json: false, hive_home: Hive::Config.hive_home, + paths: Hive::Paths.new, service_manager: nil) @subcommand = subcommand @target = target @detach = detach @@ -52,6 +57,8 @@ module Hive @all = all @json = json @hive_home = hive_home + @paths = paths + @service_manager = service_manager end def call @@ -61,6 +68,8 @@ module Hive "(expected: #{VALID_SUBCOMMANDS.join(', ')})" end + return call_service_manager if service_managed_subcommand? + case @subcommand when "start" then start_daemon when "stop" then stop_daemon @@ -72,15 +81,49 @@ module Hive end def pid_file - @pid_file ||= File.join(@hive_home, ".daemon.pid") + @pid_file ||= File.join(@paths.state, "daemon.pid") end def log_file - @log_file ||= File.join(@hive_home, "logs", "daemon.log") + @log_file ||= File.join(@paths.cache, "daemon.log") end private + def service_managed_subcommand? + return false if ENV["HIVE_SERVICE_CHILD"] == "1" + return true if @service_manager + return false unless %w[start stop status].include?(@subcommand) + + File.file?(@paths.receipt) + end + + def call_service_manager + if @json + raise Hive::InvalidTaskPath, + "hive daemon #{@subcommand}: --json is not supported for installed service commands; omit --json" + end + + manager = @service_manager || begin + receipt = Hive::Install::Receipt.load(@paths) + Hive::Service::Manager.new( + paths: @paths, platform: Hive::Platform.current.first, + executable_path: receipt.executable_path + ) + end + state = case @subcommand + when "start" then manager.start! + when "stop" then manager.stop! + when "status" then manager.status + end + if @subcommand == "status" && state != :running + raise Hive::Error, "hive daemon service is #{state.to_s.tr('_', ' ')}" + end + + puts "hive: daemon service #{state.to_s.tr('_', ' ')}" unless @json + state + end + def start_daemon warn_unsupported_json_flag if @json FileUtils.mkdir_p(@hive_home) diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb index b86e8d61..96cc75ca 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -2,16 +2,36 @@ require "open3" require "fileutils" require "stringio" require "hive/config" +require "hive/paths" +require "hive/assets" +require "hive/user_config" +require "hive/install/receipt" +require "hive/platform" +require "hive/service/manager" require "hive/git_ops" require "hive/commands/init/prompts" require "hive/commands/doctor" +require "hive/dependencies" module Hive module Commands class Init - def initialize(project_path, force: false, prompts: nil) + def initialize(project_path, force: false, prompts: nil, paths: Hive::Paths.new, + provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY", + start_service: false, service_manager: nil, dependencies: nil, dependency_output: nil, + service_input: $stdin, service_output: $stderr) @project_path = File.expand_path(project_path) @force = force + @paths = paths + @provider = provider + @model = model + @credential_env = credential_env + @start_service = start_service + @service_manager = service_manager + @dependencies = dependencies || Hive::Dependencies.new + @dependency_output = dependency_output + @service_input = service_input + @service_output = service_output # Optional Prompts instance for testability. Tests inject a # pre-fed StringIO-backed instance to drive the interactive flow # without touching $stdin. Production keeps this nil so the @@ -22,32 +42,66 @@ module Hive end def call + emit_dependency_diagnostics! + Hive::Install::Receipt.load(@paths) if File.exist?(@paths.receipt) validate_git_repo! - validate_clean_tree! unless @force - ops = Hive::GitOps.new(@project_path) - if ops.hive_state_branch_exists? - raise Hive::AlreadyInitialized, - "already initialized; hive/state branch present at #{@project_path}" + unless ops.hive_state_branch_exists? + validate_clean_tree! unless @force + + # Prompt placement is load-bearing (per ADR-023): it runs before + # any project write. A re-run skips it and simply refreshes only + # manifest-owned distribution files, making init genuinely safe to + # repeat after a user has edited their project. + answers = collect_prompt_answers + ops.hive_state_init + write_per_project_config(ops, answers: answers) + ops.add_hive_state_to_master_gitignore! end - # Prompt placement is load-bearing (per ADR-023): runs AFTER the - # already-initialized guard above, BEFORE any disk writes below. - # An aborted prompt (`n` at confirmation) leaves zero footprint — - # no orphan branch, no worktree, no master .gitignore update — - # so a re-run of `hive init` proceeds normally. - answers = collect_prompt_answers - - ops.hive_state_init - write_per_project_config(ops, answers: answers) - ops.add_hive_state_to_master_gitignore! - entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path) + distribution = setup_distribution! + setup_service_registration! print_summary(entry: entry, ops: ops) + warn "hive: preserved modified project files: #{distribution.preserved.join(', ')}" unless distribution.preserved.empty? run_init_preflight! end + def setup_distribution! + Hive::UserConfig.ensure!( + paths: @paths, provider: @provider, model: @model, credential_env: @credential_env + ) + Hive::Assets.hydrate!(paths: @paths, project_root: @project_path) + end + + def emit_dependency_diagnostics! + @dependencies.diagnostics_for(:init).each do |diagnostic| + (@dependency_output || $stderr).puts "hive: dependency: #{diagnostic.hint}" + end + end + + def setup_service_registration! + return unless File.file?(@paths.receipt) + + receipt = Hive::Install::Receipt.load(@paths) + manager = @service_manager || Hive::Service::Manager.new( + paths: @paths, platform: Hive::Platform.current.first, + executable_path: receipt.executable_path + ) + manager.register! + manager.start! if service_start_consented? + end + + def service_start_consented? + return true if @start_service + return false unless @service_input.respond_to?(:tty?) && @service_input.tty? + + @service_output.print "Start the registered Hive daemon service now? [y/N] " + @service_output.flush + @service_input.gets.to_s.strip.casecmp("y").zero? + end + # Non-fatal skill preflight: after init succeeds, run the doctor # against the freshly-written config and emit stderr warnings for # any `:missing` rows. Init's exit code is unaffected — install diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb new file mode 100644 index 00000000..0c11e79b --- /dev/null +++ b/lib/hive/commands/uninstall.rb @@ -0,0 +1,152 @@ +require "digest" +require "fileutils" +require "pathname" +require "yaml" +require "hive/install/channel" +require "hive/install/receipt" +require "hive/platform" +require "hive/service/manager" + +module Hive + module Commands + class Uninstall + def initialize(paths:, project_root: Dir.pwd, purge: false, input: $stdin, output: $stdout, + runner: nil, service_manager: nil) + @paths = paths + @project_root = File.expand_path(project_root) + @purge = purge + @input = input + @output = output + @runner = runner + @service_manager = service_manager + end + + def call + receipt = Hive::Install::Receipt.load(@paths) + manager = @service_manager || Hive::Service::Manager.new( + paths: @paths, platform: Hive::Platform.current.first, executable_path: receipt.executable_path + ) + cleanup_plan = cleanup_confirmed? ? project_cleanup_plan : nil + + begin + Hive::Install::Channel.for(receipt, runner: @runner).remove! + rescue Hive::Error => e + raise Hive::Error, + "#{e.message}. No service or project files were removed; " \ + "the installation remains registered. Fix the package-manager error and retry uninstall." + end + + manager.unregister! + summary = cleanup_plan ? apply_project_cleanup(cleanup_plan) : { "removed" => [], "retained" => [] } + summary["shared_removed"] = cleanup_shared_assets + File.delete(@paths.receipt) if File.exist?(@paths.receipt) + @output.puts YAML.dump(summary) if @output + summary + end + + private + + def cleanup_confirmed? + return true if @purge + return false unless @input.respond_to?(:tty?) && @input.tty? + + @output.print "Remove unchanged Hive-generated files from #{@project_root}/.hive? [y/N] " + @output.flush + @input.gets.to_s.strip.casecmp("y").zero? + end + + def project_cleanup_plan + directory = File.join(@project_root, ".hive") + manifest_path = File.join(directory, "install-manifest.yml") + return { directory: directory, manifest: manifest_path, files: {}, retained: [] } unless File.file?(manifest_path) + + stat = File.lstat(directory) + raise Hive::ConfigError, "project .hive directory must not be a symlink" if stat.symlink? + + manifest = YAML.safe_load(File.read(manifest_path), permitted_classes: [], aliases: false) + files = manifest.is_a?(Hash) && manifest["files"].is_a?(Hash) ? manifest["files"] : {} + candidates = {} + retained = [] + files.each do |relative, hash| + path = safe_manifest_path!(directory, relative) + if regular_file_without_symlinks?(directory, relative, path) && Digest::SHA256.file(path).hexdigest == hash + candidates[relative] = hash + else + retained << relative if File.exist?(path) + end + end + unknown = Dir.children(directory) - files.keys.map { |path| path.split("/").first } - [ "install-manifest.yml" ] + retained.concat(unknown) + { directory: directory, manifest: manifest_path, files: candidates, retained: retained } + rescue Psych::Exception => e + raise Hive::ConfigError, "project install manifest is malformed: #{e.message}" + end + + def apply_project_cleanup(plan) + removed = [] + retained = plan.fetch(:retained).dup + plan.fetch(:files).each do |relative, digest| + path = safe_manifest_path!(plan.fetch(:directory), relative) + if regular_file_without_symlinks?(plan.fetch(:directory), relative, path) && + Digest::SHA256.file(path).hexdigest == digest + File.delete(path) + removed << relative + else + retained << relative if File.exist?(path) + end + end + File.delete(plan.fetch(:manifest)) if retained.empty? && File.exist?(plan.fetch(:manifest)) + { "removed" => removed.sort, "retained" => retained.uniq.sort } + end + + def safe_manifest_path!(directory, relative) + if File.symlink?(directory) || !File.directory?(directory) + raise Hive::ConfigError, "project .hive directory must be a real directory" + end + + unless relative.is_a?(String) && !relative.empty? + raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}" + end + + pathname = Pathname.new(relative) + normalized = pathname.cleanpath.to_s + if pathname.absolute? || normalized != relative || normalized == "." || normalized.start_with?("../") + raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}" + end + + path = File.expand_path(relative, directory) + root_prefix = "#{File.expand_path(directory)}#{File::SEPARATOR}" + raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}" unless path.start_with?(root_prefix) + + path + end + + def regular_file_without_symlinks?(directory, relative, path) + parent = directory + relative.split(File::SEPARATOR)[0...-1].each do |component| + parent = File.join(parent, component) + next unless File.exist?(parent) || File.symlink?(parent) + + raise Hive::ConfigError, "unsafe symlinked parent in project install manifest path #{relative.inspect}" if File.lstat(parent).symlink? + end + File.exist?(path) && File.lstat(path).file? + end + + def cleanup_shared_assets + removed = [] + if File.symlink?(@paths.current_assets) || File.file?(@paths.current_assets) + FileUtils.rm_f(@paths.current_assets) + removed << @paths.current_assets + end + if File.symlink?(@paths.releases) + FileUtils.rm_f(@paths.releases) + removed << @paths.releases + elsif File.directory?(@paths.releases) + FileUtils.rm_rf(@paths.releases) + removed << @paths.releases + end + removed + end + end + end +end diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb new file mode 100644 index 00000000..38a76ffb --- /dev/null +++ b/lib/hive/commands/update.rb @@ -0,0 +1,29 @@ +require "hive/install/channel" +require "hive/install/receipt" +require "hive/dependencies" + +module Hive + module Commands + class Update + def initialize(paths:, runner: nil, output: $stdout, dependencies: nil) + @paths = paths + @runner = runner + @output = output + @dependencies = dependencies || Hive::Dependencies.new + end + + def call + receipt = Hive::Install::Receipt.load(@paths) + diagnostics = @dependencies.diagnostics_for(:"update_#{receipt.channel}") + diagnostics.each { |diagnostic| @output.puts "hive: dependency: #{diagnostic.hint}" } + unless diagnostics.empty? + raise Hive::Error, "missing dependencies for #{receipt.channel} update: #{diagnostics.map(&:tool).join(', ')}" + end + channel = Hive::Install::Channel.for(receipt, runner: @runner) + @output.puts "hive: delegating update to #{receipt.channel} for #{receipt.package_id}" + channel.update! + true + end + end + end +end diff --git a/lib/hive/dependencies.rb b/lib/hive/dependencies.rb new file mode 100644 index 00000000..7da1b4dc --- /dev/null +++ b/lib/hive/dependencies.rb @@ -0,0 +1,54 @@ +require "hive/platform" + +module Hive + # Read-only diagnostics only. This deliberately never invokes a package + # manager: users decide if and how they install a missing prerequisite. + class Dependencies + Diagnostic = Data.define(:tool, :hint, :required) + + ACTION_TOOLS = { + init: %w[git bash claude gh jq], + update_bash: %w[bash curl], + update_homebrew: %w[brew], + bash_install: %w[bash curl], + agent_assist: %w[claude gh jq], + daemon_status: [] + }.freeze + + def initialize(which: nil, platform: nil) + @which = which || ->(tool) { ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, tool)) } } + @platform = platform || Platform.current.first + end + + def diagnostics_for(action) + tools_for(action).filter_map do |tool| + next if @which.call(tool) + + Diagnostic.new(tool, hint_for(tool), true) + end + end + + def missing_for(action) + diagnostics_for(action) + end + + private + + def tools_for(action) + return [ ENV.fetch("HIVE_AUR_HELPER", "yay") ] if action.to_sym == :update_aur + + ACTION_TOOLS.fetch(action.to_sym) { [] } + end + + def hint_for(tool) + return "brew is required for this Homebrew lifecycle action; install Homebrew from https://brew.sh and rerun Hive." if tool == "brew" + if %w[yay paru].include?(tool) + return "#{tool} is required for this AUR lifecycle action; install or select an AUR helper with HIVE_AUR_HELPER and rerun Hive." + end + return "claude is required for Claude-powered stages; install Claude Code and ensure `claude` is on PATH." if tool == "claude" + + manager = @platform == :macos ? "Homebrew (brew install #{tool})" : "your package manager (apt or pacman install #{tool})" + "#{tool} is required; install it with #{manager} and rerun Hive." + end + end +end diff --git a/lib/hive/install/aur.rb b/lib/hive/install/aur.rb new file mode 100644 index 00000000..ad1e4a6f --- /dev/null +++ b/lib/hive/install/aur.rb @@ -0,0 +1,21 @@ +require "open3" + +module Hive + module Install + class Aur < ChannelAdapter + def update! + run!(helper, "-Syu", @receipt.package_id) + end + + def remove! + run!(helper, "-Rns", @receipt.package_id) + end + + private + + def helper + ENV.fetch("HIVE_AUR_HELPER", "yay") + end + end + end +end diff --git a/lib/hive/install/bash.rb b/lib/hive/install/bash.rb new file mode 100644 index 00000000..ebda0518 --- /dev/null +++ b/lib/hive/install/bash.rb @@ -0,0 +1,30 @@ +require "open3" +require "shellwords" + +module Hive + module Install + class Bash < ChannelAdapter + def update! + run!("bash", "-o", "pipefail", "-c", installer_pipeline) + end + + def remove! + # The installer owns post-exit cleanup so a running Hive binary is + # never truncated or unlinked in place by itself. + run!("bash", "-o", "pipefail", "-c", "#{installer_pipeline} -s -- --uninstall") + end + + private + + def installer_url + "#{@receipt.release_base_url.sub(%r{/*$}, '')}/install.sh" + end + + def installer_pipeline + bin_dir = Shellwords.escape(File.dirname(@receipt.executable_path)) + version = Shellwords.escape("v#{@receipt.installed_version}") + "curl -fsSL #{Shellwords.escape(installer_url)} | HIVE_BIN_DIR=#{bin_dir} HIVE_VERSION=#{version} bash" + end + end + end +end diff --git a/lib/hive/install/channel.rb b/lib/hive/install/channel.rb new file mode 100644 index 00000000..4db1883c --- /dev/null +++ b/lib/hive/install/channel.rb @@ -0,0 +1,47 @@ +module Hive + module Install + class ChannelAdapter + def initialize(receipt, runner: nil) + @receipt = receipt + @runner = runner || method(:default_runner) + end + + private + + def run!(*command) + _out, err, success = @runner.call(*command) + return if success + + raise Hive::Error, "#{command.first} failed while delegating Hive #{operation_name}; #{err}" + end + + def default_runner(*command) + out, err, status = Open3.capture3(*command) + [out, err, status.success?] + end + + def operation_name + "lifecycle operation" + end + end + end +end + +require "hive/install/homebrew" +require "hive/install/aur" +require "hive/install/bash" + +module Hive + module Install + class Channel + def self.for(receipt, runner: nil) + case receipt.channel + when "homebrew" then Homebrew.new(receipt, runner: runner) + when "aur" then Aur.new(receipt, runner: runner) + when "bash" then Bash.new(receipt, runner: runner) + else raise Hive::Install::Receipt::Invalid, "unknown install channel #{receipt.channel.inspect}; repair the receipt manually" + end + end + end + end +end diff --git a/lib/hive/install/homebrew.rb b/lib/hive/install/homebrew.rb new file mode 100644 index 00000000..d42bc728 --- /dev/null +++ b/lib/hive/install/homebrew.rb @@ -0,0 +1,15 @@ +require "open3" + +module Hive + module Install + class Homebrew < ChannelAdapter + def update! + run!("brew", "upgrade", @receipt.package_id) + end + + def remove! + run!("brew", "uninstall", @receipt.package_id) + end + end + end +end diff --git a/lib/hive/install/receipt.rb b/lib/hive/install/receipt.rb new file mode 100644 index 00000000..42a1bc27 --- /dev/null +++ b/lib/hive/install/receipt.rb @@ -0,0 +1,187 @@ +require "fileutils" +require "securerandom" +require "yaml" +require "hive/command_name" + +module Hive + module Install + # Durable declaration of who owns the executable. Update and uninstall use + # this receipt to delegate to the right channel instead of guessing or + # overwriting package-manager-owned files. + class Receipt + class Invalid < Hive::ConfigError; end + + SCHEMA_VERSION = 1 + CHANNELS = %w[bash homebrew aur].freeze + FIELDS = %w[channel package_id release_base_url installed_version executable_path command_name].freeze + PACKAGE_IDS = { + "bash" => "hive", + "homebrew" => "ivankuznetsov/hive/hive", + "aur" => "hive-bin" + }.freeze + STABLE_VERSION = /\A\d+\.\d+\.\d+\z/ + RELEASE_ROOT = "https://github.com/ivankuznetsov/hive/releases/download".freeze + + attr_reader(*FIELDS.map(&:to_sym)) + + def self.load(paths) + path = paths.receipt + raise Invalid, recovery_message("receipt is missing at #{path}") unless File.file?(path) + + raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) + raise Invalid, recovery_message("receipt at #{path} must be a mapping") unless raw.is_a?(Hash) + + new(**raw.transform_keys(&:to_sym)) + rescue Psych::Exception => e + raise Invalid, recovery_message("receipt at #{path} is malformed (#{e.message})") + end + + def self.recovery_message(problem) + "#{problem}; repair or remove it manually before retrying update or uninstall" + end + + def self.ensure_native!(paths:, program_name: $PROGRAM_NAME) + executable_path = resolve_executable(program_name) + return unless executable_path + + real_executable = File.realpath(executable_path) + prefix = File.dirname(File.dirname(real_executable)) + marker_path = File.join(prefix, "share", "hive", "install-channel.yml") + return unless File.file?(marker_path) + + marker = YAML.safe_load(File.read(marker_path), permitted_classes: [], aliases: false) + unless marker.is_a?(Hash) && %w[homebrew aur].include?(marker["channel"]) && + marker["package_id"] == PACKAGE_IDS.fetch(marker["channel"]) + raise Invalid, recovery_message("native package marker at #{marker_path} is invalid") + end + + existing = nil + if File.file?(paths.receipt) + existing = load(paths) + unless existing.channel == marker.fetch("channel") && existing.package_id == marker.fetch("package_id") + raise Invalid, recovery_message( + "native #{marker.fetch('channel')} package conflicts with the recorded #{existing.channel} installation" + ) + end + end + + recorded_executable = + if existing && File.executable?(existing.executable_path) + existing.executable_path + else + executable_path + end + receipt = new( + channel: marker.fetch("channel"), + package_id: marker.fetch("package_id"), + release_base_url: "#{RELEASE_ROOT}/v#{Hive::VERSION}", + installed_version: Hive::VERSION, + executable_path: recorded_executable, + command_name: File.basename(recorded_executable) + ) + receipt.write!(paths) unless existing && existing.to_h == receipt.to_h + receipt + rescue Psych::Exception, Errno::ENOENT, Errno::EACCES => e + raise Invalid, recovery_message("native package marker could not be read (#{e.message})") + end + + def self.resolve_executable(program_name) + candidate = program_name.to_s + if candidate.include?(File::SEPARATOR) + path = File.expand_path(candidate) + return path if File.file?(path) && File.executable?(path) + + return nil + end + + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory| + path = File.expand_path(File.join(directory, candidate)) + return path if File.file?(path) && File.executable?(path) + end + nil + end + + def initialize(schema_version: SCHEMA_VERSION, **values) + raise Invalid, self.class.recovery_message("unsupported receipt schema #{schema_version.inspect}") unless schema_version == SCHEMA_VERSION + + FIELDS.each do |field| + value = values[field.to_sym] + raise Invalid, self.class.recovery_message("receipt field #{field} is missing") unless value.is_a?(String) && !value.empty? + + instance_variable_set("@#{field}", value) + end + unless CHANNELS.include?(channel) + raise Invalid, self.class.recovery_message("receipt channel #{channel.inspect} is unknown") + end + unless CommandName::ALIASES.include?(command_name) + raise Invalid, self.class.recovery_message("receipt command_name #{command_name.inspect} is invalid") + end + + validate_identity! + end + + def to_h + { "schema_version" => SCHEMA_VERSION }.merge(FIELDS.to_h { |field| [ field, public_send(field) ] }) + end + + def write!(paths) + path = paths.receipt + FileUtils.mkdir_p(File.dirname(path), mode: 0o700) + temp = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}" + File.open(temp, "w", 0o600) do |file| + file.write(YAML.dump(to_h)) + file.flush + file.fsync + end + File.rename(temp, path) + File.chmod(0o600, path) + ensure + File.delete(temp) if defined?(temp) && temp && File.exist?(temp) + end + + private + + def validate_identity! + expected_package = PACKAGE_IDS.fetch(channel) + unless package_id == expected_package + raise Invalid, self.class.recovery_message( + "receipt package_id #{package_id.inspect} does not match #{channel} package #{expected_package.inspect}" + ) + end + + unless STABLE_VERSION.match?(installed_version) + raise Invalid, self.class.recovery_message("receipt installed_version #{installed_version.inspect} is invalid") + end + + expected_release = "#{RELEASE_ROOT}/v#{installed_version}" + unless release_base_url == expected_release + raise Invalid, self.class.recovery_message( + "receipt release_base_url #{release_base_url.inspect} does not match #{expected_release.inspect}" + ) + end + + normalized = File.expand_path(executable_path) + unless executable_path == normalized && File.basename(executable_path) == command_name + raise Invalid, self.class.recovery_message( + "receipt executable_path #{executable_path.inspect} is not an absolute path for #{command_name.inspect}" + ) + end + + case channel + when "homebrew" + unless executable_path.match?(%r{\A/.+/(?:homebrew[^/]*|\.linuxbrew)/bin/(?:hive|hv)\z}) + raise Invalid, self.class.recovery_message( + "receipt executable_path #{executable_path.inspect} is not the Homebrew arm64 prefix command" + ) + end + when "aur" + unless %W[/usr/bin/#{command_name}].include?(executable_path) + raise Invalid, self.class.recovery_message( + "receipt executable_path #{executable_path.inspect} is not the AUR package command" + ) + end + end + end + end + end +end diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb new file mode 100644 index 00000000..9c4cbcbe --- /dev/null +++ b/lib/hive/paths.rb @@ -0,0 +1,44 @@ +module Hive + # Writable locations for distribution-owned content. Existing project-local + # .hive-state content remains a separate, read-only compatibility location; + # this resolver must never migrate or delete it implicitly. + class Paths + attr_reader :home, :config, :data, :state, :cache, :legacy_state + + def initialize(env: ENV, home: Dir.home) + @home = File.expand_path(home) + @config = under(env["XDG_CONFIG_HOME"], ".config") + @data = under(env["XDG_DATA_HOME"], ".local/share") + @state = under(env["XDG_STATE_HOME"], ".local/state") + @cache = under(env["XDG_CACHE_HOME"], ".cache") + @legacy_state = File.join(@home, ".hive-state") + end + + def config_file + File.join(config, "config.yml") + end + + def receipt + File.join(state, "install.yml") + end + + def releases + File.join(data, "releases") + end + + def current_assets + File.join(data, "current") + end + + def systemd_user_dir + File.join(File.dirname(config), "systemd", "user") + end + + private + + def under(override, fallback) + root = override.nil? || override.empty? ? File.join(@home, fallback) : File.expand_path(override) + File.join(root, "hive") + end + end +end diff --git a/lib/hive/platform.rb b/lib/hive/platform.rb new file mode 100644 index 00000000..08fc2d27 --- /dev/null +++ b/lib/hive/platform.rb @@ -0,0 +1,28 @@ +require "rbconfig" + +module Hive + # Small, injectable platform classifier shared by the installer, service + # adapters, and dependency diagnostics. Keeping this separate prevents each + # lifecycle command from growing a subtly different host check. + module Platform + module_function + + def current(host_os: RbConfig::CONFIG.fetch("host_os"), host_cpu: RbConfig::CONFIG.fetch("host_cpu")) + os = case host_os + when /darwin/i then :macos + when /linux/i then :linux + else :unknown + end + arch = case host_cpu + when "arm64", "aarch64" then :arm64 + when "x86_64", "amd64" then :x86_64 + else host_cpu.to_sym + end + [os, arch] + end + + def supported?(os, arch) + (os == :macos && arch == :arm64) || (os == :linux && arch == :x86_64) + end + end +end diff --git a/lib/hive/release_manifest.rb b/lib/hive/release_manifest.rb new file mode 100644 index 00000000..74174416 --- /dev/null +++ b/lib/hive/release_manifest.rb @@ -0,0 +1,53 @@ +require "digest" +require "json" + +module Hive + class ReleaseManifest + TARGETS = [ + { "os" => "darwin", "arch" => "arm64", "minimum_os" => "macOS 14" }, + { "os" => "linux", "arch" => "x86_64", "minimum_os" => "Ubuntu 22.04", "minimum_glibc" => "2.35" } + ].freeze + SEMVER_TAG = /\Av\d+\.\d+\.\d+\z/ + + def self.generate!(dist:, version:, release_base_url: nil) + raise ArgumentError, "release version must be a stable vMAJOR.MINOR.PATCH semver tag" unless SEMVER_TAG.match?(version) + raise ArgumentError, "release version #{version} disagrees with Hive::VERSION #{Hive::VERSION}" unless version.delete_prefix("v") == Hive::VERSION + + release_base_url ||= "https://github.com/ivankuznetsov/hive/releases/download/#{version}" + canonical_url = "https://github.com/ivankuznetsov/hive/releases/download/#{version}" + raise ArgumentError, "release base URL must be the canonical GitHub release URL #{canonical_url}" unless release_base_url == canonical_url + + targets = TARGETS.map do |target| + name = "hive_#{Hive::VERSION}_#{target.fetch('os')}_#{target.fetch('arch')}" + artifact = File.join(dist, name) + raise ArgumentError, "missing release artifact #{artifact}" unless File.file?(artifact) + + target.merge( + "name" => name, + "url" => "#{release_base_url}/#{name}", + "size" => File.size(artifact), + "sha256" => Digest::SHA256.file(artifact).hexdigest + ) + end + installer = File.join(dist, "install.sh") + raise ArgumentError, "missing release installer #{installer}" unless File.file?(installer) + + manifest = { + "schema_version" => 1, + "version" => version, + "release_base_url" => release_base_url, + "targets" => targets, + "installer" => { + "name" => "install.sh", + "url" => "#{release_base_url}/install.sh", + "size" => File.size(installer), + "sha256" => Digest::SHA256.file(installer).hexdigest + } + } + File.write(File.join(dist, "release-manifest.json"), JSON.pretty_generate(manifest) + "\n") + checksums = (targets.map { |target| [ target.fetch("sha256"), target.fetch("name") ] } + [ [ manifest.dig("installer", "sha256"), "install.sh" ] ]).map { |digest, name| "#{digest} #{name}" } + File.write(File.join(dist, "SHA256SUMS"), checksums.join("\n") + "\n") + manifest + end + end +end diff --git a/lib/hive/service/launchd.rb b/lib/hive/service/launchd.rb new file mode 100644 index 00000000..7ef40b2c --- /dev/null +++ b/lib/hive/service/launchd.rb @@ -0,0 +1,49 @@ +require "cgi" +require "fileutils" + +module Hive + module Service + module Launchd + LABEL = "dev.hive.daemon".freeze + + module_function + + def path(paths) + File.join(paths.home, "Library", "LaunchAgents", "#{LABEL}.plist") + end + + def write!(paths:, executable_path:) + FileUtils.mkdir_p(File.dirname(path(paths)), mode: 0o700) + executable = CGI.escapeHTML(executable_path) + environment = { + "XDG_CONFIG_HOME" => File.dirname(paths.config), + "XDG_DATA_HOME" => File.dirname(paths.data), + "XDG_STATE_HOME" => File.dirname(paths.state), + "XDG_CACHE_HOME" => File.dirname(paths.cache), + "HIVE_STATE_DIR" => paths.state + } + environment_xml = environment.map do |key, value| + " #{key}#{CGI.escapeHTML(value)}" + end.join("\n") + content = <<~PLIST + + + + Label#{LABEL} + ProgramArguments + #{executable}daemonstart + + EnvironmentVariables + HIVE_SERVICE_CHILD1 + #{environment_xml} + + RunAtLoad + KeepAlive + + PLIST + File.write(path(paths), content.lstrip) + path(paths) + end + end + end +end diff --git a/lib/hive/service/manager.rb b/lib/hive/service/manager.rb new file mode 100644 index 00000000..56c857a4 --- /dev/null +++ b/lib/hive/service/manager.rb @@ -0,0 +1,107 @@ +require "open3" +require "hive/service/launchd" +require "hive/service/systemd_user" + +module Hive + module Service + # Adapter for the two supported per-user service managers. Files are + # registered first; enabling or starting is an explicit separate action. + class Manager + def initialize(paths:, platform:, executable_path:, runner: nil, uid: Process.uid) + @paths = paths + @platform = platform.to_sym + @executable_path = executable_path + @uid = uid + @runner = runner || lambda do |*command| + out, err, status = Open3.capture3(*command) + [ out, err, status.success? ] + end + end + + def register! + write_definition + :registered_stopped + end + + def start! + register! unless definition_exists? + case @platform + when :macos + execute!("launchctl", "bootstrap", "gui/#{@uid}", definition_path) + execute!("launchctl", "kickstart", "-k", "gui/#{@uid}/#{Launchd::LABEL}") + when :linux + execute!("systemctl", "--user", "daemon-reload") + execute!("systemctl", "--user", "enable", "--now", SystemdUser::UNIT_NAME) + else + raise Hive::Error, "per-user daemon services are unsupported on #{@platform}" + end + :running + end + + def stop! + return :unregistered unless definition_exists? + + case @platform + when :macos + _out, _err, running = execute("launchctl", "print", "gui/#{@uid}/#{Launchd::LABEL}") + return :stopped unless running + + execute!("launchctl", "bootout", "gui/#{@uid}/#{Launchd::LABEL}") + when :linux + execute!("systemctl", "--user", "disable", "--now", SystemdUser::UNIT_NAME) + end + :stopped + end + + def status + return :unregistered unless definition_exists? + + command = @platform == :macos ? [ "launchctl", "print", "gui/#{@uid}/#{Launchd::LABEL}" ] : [ "systemctl", "--user", "is-active", "--quiet", SystemdUser::UNIT_NAME ] + _out, _err, success = execute(*command) + success ? :running : :registered_stopped + end + + def unregister! + stop! + File.delete(definition_path) if File.exist?(definition_path) + :unregistered + end + + private + + def definition_path + @platform == :macos ? Launchd.path(@paths) : SystemdUser.path(@paths) + end + + def definition_exists? + File.file?(definition_path) + end + + def write_definition + case @platform + when :macos then Launchd.write!(paths: @paths, executable_path: @executable_path) + when :linux then SystemdUser.write!(paths: @paths, executable_path: @executable_path) + else raise Hive::Error, "per-user daemon services are unsupported on #{@platform}" + end + end + + def execute(*command) + result = @runner.call(*command) + return [ result[0], "", !!result[1] ] if result.size == 2 + + status = result[2] + success = status.respond_to?(:success?) ? status.success? : !!status + [ result[0], result[1].to_s, success ] + end + + def execute!(*command) + out, err, success = execute(*command) + return out if success + + detail = err.strip + detail = "no diagnostic output" if detail.empty? + raise Hive::Error, "#{command.join(' ')} failed: #{detail}" + end + end + end +end diff --git a/lib/hive/service/systemd_user.rb b/lib/hive/service/systemd_user.rb new file mode 100644 index 00000000..d2f0b8b2 --- /dev/null +++ b/lib/hive/service/systemd_user.rb @@ -0,0 +1,57 @@ +require "fileutils" + +module Hive + module Service + module SystemdUser + UNIT_NAME = "hive.service".freeze + + module_function + + def path(paths) + File.join(paths.systemd_user_dir, UNIT_NAME) + end + + def write!(paths:, executable_path:) + FileUtils.mkdir_p(paths.systemd_user_dir, mode: 0o700) + File.write(path(paths), <<~UNIT) + [Unit] + Description=Hive daemon + + [Service] + Type=simple + Environment=#{quote_environment("HIVE_SERVICE_CHILD", "1")} + Environment=#{quote_environment("XDG_CONFIG_HOME", File.dirname(paths.config))} + Environment=#{quote_environment("XDG_DATA_HOME", File.dirname(paths.data))} + Environment=#{quote_environment("XDG_STATE_HOME", File.dirname(paths.state))} + Environment=#{quote_environment("XDG_CACHE_HOME", File.dirname(paths.cache))} + Environment=#{quote_environment("HIVE_STATE_DIR", paths.state)} + ExecStart=#{quote_argument(executable_path)} daemon start + Restart=on-failure + RestartSec=5 + + [Install] + WantedBy=default.target + UNIT + path(paths) + end + + def quote_environment(key, value) + %Q("#{escape(value, dollar: false).prepend("#{key}=")}") + end + + def quote_argument(value) + %Q("#{escape(value, dollar: true)}") + end + + def escape(value, dollar:) + escaped = value.to_s + .gsub("\\") { "\\\\" } + .gsub('"') { '\\"' } + .gsub("%", "%%") + .gsub("\n", "\\n") + .gsub("\r", "\\r") + dollar ? escaped.gsub("$", "$$") : escaped + end + end + end +end diff --git a/lib/hive/user_config.rb b/lib/hive/user_config.rb new file mode 100644 index 00000000..4bf5009e --- /dev/null +++ b/lib/hive/user_config.rb @@ -0,0 +1,60 @@ +require "fileutils" +require "securerandom" +require "yaml" + +module Hive + # Distribution-level provider preferences. This is intentionally separate + # from per-project pipeline configuration in .hive-state/config.yml. + module UserConfig + module_function + + ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/ + + def ensure!(paths:, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY") + return load(paths) if File.file?(paths.config_file) + + values = { + "schema_version" => 1, + "provider" => nonempty!(provider, "provider"), + "model" => nonempty!(model, "model"), + "credential_env" => credential_env_name!(credential_env) + } + write(paths.config_file, values) + values + end + + def load(paths) + values = YAML.safe_load(File.read(paths.config_file), permitted_classes: [], aliases: false) + raise Hive::ConfigError, "user config at #{paths.config_file} must be a mapping" unless values.is_a?(Hash) + + %w[provider model credential_env].each { |field| nonempty!(values[field], field) } + credential_env_name!(values.fetch("credential_env")) + values + rescue Psych::Exception => e + raise Hive::ConfigError, "user config at #{paths.config_file} is malformed: #{e.message}" + end + + def nonempty!(value, field) + raise Hive::ConfigError, "user config #{field} must be a non-empty string" unless value.is_a?(String) && !value.empty? + + value + end + + def credential_env_name!(value) + nonempty!(value, "credential_env") + raise Hive::ConfigError, "credential_env must be an environment-variable name, not a secret value" unless ENV_NAME.match?(value) + + value + end + + def write(path, values) + FileUtils.mkdir_p(File.dirname(path), mode: 0o700) + temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}" + File.open(temporary, "w", 0o600) { |file| file.write(YAML.dump(values)) } + File.rename(temporary, path) + File.chmod(0o600, path) + ensure + File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary) + end + end +end diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO new file mode 100644 index 00000000..d6afbfa2 --- /dev/null +++ b/packaging/aur/.SRCINFO @@ -0,0 +1,14 @@ +pkgbase = hive-bin + pkgdesc = Folder-as-agent software delivery pipeline (prebuilt binary) + pkgver = 0.1.0 + pkgrel = 1 + url = https://github.com/ivankuznetsov/hive + arch = x86_64 + license = MIT + depends = glibc + source = install-channel.yml + sha256sums = cb34e670f715c31be6c221475ae8a0e023c7f5d8fa97256382335a9b0371c76b + source_x86_64 = hive_0.1.0_linux_x86_64::https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive_0.1.0_linux_x86_64 + sha256sums_x86_64 = 0000000000000000000000000000000000000000000000000000000000000000 + +pkgname = hive-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 00000000..40f1621b --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,19 @@ +pkgname=hive-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Folder-as-agent software delivery pipeline (prebuilt binary)' +arch=('x86_64') +url='https://github.com/ivankuznetsov/hive' +license=('MIT') +depends=('glibc') +source=('install-channel.yml') +source_x86_64=("hive_${pkgver}_linux_x86_64::https://github.com/ivankuznetsov/hive/releases/download/v${pkgver}/hive_${pkgver}_linux_x86_64") +# Replaced from the canonical release manifest before AUR publication. +sha256sums=('cb34e670f715c31be6c221475ae8a0e023c7f5d8fa97256382335a9b0371c76b') +sha256sums_x86_64=('0000000000000000000000000000000000000000000000000000000000000000') + +package() { + install -Dm755 "$srcdir/hive_${pkgver}_linux_x86_64" "$pkgdir/usr/bin/hive" + ln -s hive "$pkgdir/usr/bin/hv" + install -Dm644 "$srcdir/install-channel.yml" "$pkgdir/usr/share/hive/install-channel.yml" +} diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 00000000..775cf798 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,6 @@ +# AUR source + +This directory is the deterministic source for the `hive-bin` AUR repository. +It installs only `/usr/bin/hive` and `/usr/bin/hv`; package hooks never write a +user home directory or start `systemd --user`. Release automation replaces the +version, URL, checksum, and generated `.SRCINFO` from the canonical manifest. diff --git a/packaging/aur/hive-bin.install b/packaging/aur/hive-bin.install new file mode 100644 index 00000000..c6c9396a --- /dev/null +++ b/packaging/aur/hive-bin.install @@ -0,0 +1,7 @@ +post_install() { + echo 'Run hive init in a project to register the optional per-user systemd service.' +} + +post_upgrade() { + echo 'Hive preserves XDG configuration and project work; run hive init to refresh service registration if needed.' +} diff --git a/packaging/aur/install-channel.yml b/packaging/aur/install-channel.yml new file mode 100644 index 00000000..c28e3779 --- /dev/null +++ b/packaging/aur/install-channel.yml @@ -0,0 +1,2 @@ +channel: aur +package_id: hive-bin diff --git a/packaging/hive-skills/.agents/plugins/marketplace.json b/packaging/hive-skills/.agents/plugins/marketplace.json new file mode 100644 index 00000000..30b93fcf --- /dev/null +++ b/packaging/hive-skills/.agents/plugins/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "hive-skills", + "interface": { "displayName": "Hive skills" }, + "plugins": [ + { + "name": "hive", + "source": { "source": "local", "path": "./plugins/hive" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Productivity" + } + ] +} diff --git a/packaging/hive-skills/.claude-plugin/marketplace.json b/packaging/hive-skills/.claude-plugin/marketplace.json new file mode 100644 index 00000000..64af1528 --- /dev/null +++ b/packaging/hive-skills/.claude-plugin/marketplace.json @@ -0,0 +1,7 @@ +{ + "name": "hive-skills", + "owner": { "name": "Hive maintainers" }, + "plugins": [ + { "name": "hive", "source": "./plugins/hive", "description": "Operate an installed Hive workflow safely." } + ] +} diff --git a/packaging/hive-skills/.codex-plugin/plugin.json b/packaging/hive-skills/.codex-plugin/plugin.json new file mode 100644 index 00000000..1cb08463 --- /dev/null +++ b/packaging/hive-skills/.codex-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "hive", + "version": "0.1.0", + "description": "Operate an installed Hive distribution safely.", + "author": { + "name": "Hive maintainers", + "url": "https://github.com/ivankuznetsov/hive" + }, + "homepage": "https://github.com/ivankuznetsov/hive", + "repository": "https://github.com/ivankuznetsov/hive-skills", + "license": "MIT", + "keywords": ["hive", "workflow", "agent"], + "skills": "./skills/", + "interface": { + "displayName": "Hive", + "shortDescription": "Operate an installed Hive workflow.", + "longDescription": "A host-native skill for safely operating an installed Hive distribution.", + "developerName": "Hive maintainers", + "category": "Productivity", + "capabilities": ["Interactive"], + "websiteURL": "https://github.com/ivankuznetsov/hive", + "defaultPrompt": ["Check the installed Hive version and show the next safe action."], + "brandColor": "#D97706" + } +} diff --git a/packaging/hive-skills/README.md b/packaging/hive-skills/README.md new file mode 100644 index 00000000..5da06a75 --- /dev/null +++ b/packaging/hive-skills/README.md @@ -0,0 +1,16 @@ +# Hive skills + +This separately versioned package contains agent-host adapters only; it does +not bundle Hive. Install the matching `v0.1.0` tag through the host surface: + +- Claude Code: `claude plugin marketplace add ivankuznetsov/hive-skills`, then + `claude plugin install hive@hive-skills`; remove with + `claude plugin uninstall hive@hive-skills`. +- Codex: `codex plugin marketplace add ivankuznetsov/hive-skills --ref v0.1.0`, + then `codex plugin add hive@hive-skills`; remove with + `codex plugin remove hive@hive-skills`. +- Pi: `pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0`; remove with + `pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`. + +Remove it through the same host package manager. The core Hive installer never +writes agent configuration or skill directories. diff --git a/packaging/hive-skills/package.json b/packaging/hive-skills/package.json new file mode 100644 index 00000000..b454286f --- /dev/null +++ b/packaging/hive-skills/package.json @@ -0,0 +1,8 @@ +{ + "name": "@hive/skills", + "version": "0.1.0", + "description": "Native agent skills for an installed Hive distribution", + "license": "MIT", + "repository": "github:ivankuznetsov/hive-skills", + "pi": { "skills": ["skills/hive"] } +} diff --git a/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json new file mode 100644 index 00000000..805b92ca --- /dev/null +++ b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "hive", + "version": "0.1.0", + "description": "Operate an installed Hive workflow safely." +} diff --git a/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json new file mode 100644 index 00000000..219d8eb9 --- /dev/null +++ b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "hive", + "version": "0.1.0", + "description": "Operate an installed Hive distribution safely.", + "skills": "./skills/" +} diff --git a/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md new file mode 100644 index 00000000..2aebb67f --- /dev/null +++ b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md @@ -0,0 +1,14 @@ +--- +name: hive +description: Operate an installed Hive distribution safely. +--- + +Detect the installed command once with `command -v hive` / `command -v hv`, save +the result as `HIVE_COMMAND`, and run `$HIVE_COMMAND --version` first. Reuse that +selected command for init and every later lifecycle action. Ask before a command +that changes project state. + +The core installer never manages this plugin. Claude Code removes it with +`claude plugin uninstall hive@hive-skills`; Codex uses +`codex plugin remove hive@hive-skills`; Pi uses +`pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`. diff --git a/packaging/hive-skills/skills/hive/SKILL.md b/packaging/hive-skills/skills/hive/SKILL.md new file mode 100644 index 00000000..cd87d01f --- /dev/null +++ b/packaging/hive-skills/skills/hive/SKILL.md @@ -0,0 +1,17 @@ +--- +name: hive +description: Operate an installed Hive distribution safely. +--- + +# Hive + +Detect `hive` or `hv` once, save the executable as `HIVE_COMMAND`, and reuse +`$HIVE_COMMAND` to inspect and operate the local Hive workflow. Before changing +project state, run `$HIVE_COMMAND --version` and show the proposed command. Run +`$HIVE_COMMAND init` only after the user agrees. + +Do not copy this skill into an agent-owned directory. Install it with the +native Claude Code, Codex, or Pi package/marketplace mechanism instead. +Remove it with `claude plugin uninstall hive@hive-skills`, +`codex plugin remove hive@hive-skills`, or +`pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0` for the active host. diff --git a/packaging/homebrew/.github/workflows/audit.yml b/packaging/homebrew/.github/workflows/audit.yml new file mode 100644 index 00000000..1c0d1949 --- /dev/null +++ b/packaging/homebrew/.github/workflows/audit.yml @@ -0,0 +1,10 @@ +name: Formula audit +on: [pull_request, push] +jobs: + audit: + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - run: brew audit --strict Formula/hive.rb + - run: brew install --build-from-source Formula/hive.rb + - run: hive --version diff --git a/packaging/homebrew/Formula/hive.rb b/packaging/homebrew/Formula/hive.rb new file mode 100644 index 00000000..3fee0e9f --- /dev/null +++ b/packaging/homebrew/Formula/hive.rb @@ -0,0 +1,27 @@ +class Hive < Formula + desc "Folder-as-agent software delivery pipeline" + homepage "https://github.com/ivankuznetsov/hive" + version "0.1.0" + license "MIT" + + on_arm do + url "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive_0.1.0_darwin_arm64" + # Updated atomically by script/update-homebrew-formula after a release + # manifest has been generated. This bootstrap value is intentionally not + # publishable until it is replaced with the release digest. + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + + def install + bin.install "hive_0.1.0_darwin_arm64" => "hive" + bin.install_symlink "hive" => "hv" + (share/"hive/install-channel.yml").write <<~YAML + channel: homebrew + package_id: ivankuznetsov/hive/hive + YAML + end + + test do + assert_match version.to_s, shell_output("#{bin}/hive --version") + end +end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 00000000..6163c5b1 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,6 @@ +# Homebrew tap source + +This directory is the source copied to `ivankuznetsov/homebrew-hive` by release +automation. The formula consumes only the canonical GitHub Release binary and +its SHA-256 from `release-manifest.json`; it does not install Ruby or start a +daemon in `post_install`. diff --git a/packaging/release-manifest.schema.json b/packaging/release-manifest.schema.json new file mode 100644 index 00000000..a310b767 --- /dev/null +++ b/packaging/release-manifest.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Hive release manifest", + "type": "object", + "required": ["schema_version", "version", "release_base_url", "targets", "installer"], + "properties": { + "schema_version": { "const": 1 }, + "version": { "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "release_base_url": { "type": "string", "format": "uri" }, + "targets": { + "type": "array", + "minItems": 2, + "items": { "$ref": "#/$defs/artifact" } + }, + "installer": { "$ref": "#/$defs/artifact" } + }, + "$defs": { + "artifact": { + "type": "object", + "required": ["name", "url", "size", "sha256"], + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "size": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + } +} diff --git a/script/package b/script/package new file mode 100755 index 00000000..9897643f --- /dev/null +++ b/script/package @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "rbconfig" +require "fileutils" + +ROOT = File.expand_path("..", __dir__) +VERSION_FILE = File.join(ROOT, "lib/hive.rb") +TEBAKO_RUBY_VERSION = "3.4.2" +TARGETS = [ + { "os" => "darwin", "arch" => "arm64" }, + { "os" => "linux", "arch" => "x86_64" } +].freeze +PAYLOAD_ENTRIES = %w[Gemfile Gemfile.lock exe bin lib assets templates schemas].freeze + +def hive_version + File.read(VERSION_FILE).match(/VERSION = "([^"]+)"/)[1] +end + +def contract + version = hive_version + { + "version" => version, + "ruby" => TEBAKO_RUBY_VERSION, + "packager" => "tebako", + "payload_entries" => PAYLOAD_ENTRIES, + "targets" => TARGETS.map { |target| target.merge("name" => "hive_#{version}_#{target.fetch('os')}_#{target.fetch('arch')}") } + } +end + +def host_target + os = case RbConfig::CONFIG.fetch("host_os") + when /darwin/ then "darwin" + when /linux/ then "linux" + else raise "unsupported packaging host: #{RbConfig::CONFIG.fetch('host_os')}" + end + arch = RbConfig::CONFIG.fetch("host_cpu") + arch = "arm64" if arch == "aarch64" + { "os" => os, "arch" => arch } +end + +if ARGV == ["--print-contract"] + puts JSON.generate(contract) + exit 0 +end + +requested = ARGV.empty? ? host_target : { "os" => ARGV.fetch(0), "arch" => ARGV.fetch(1) } +unless TARGETS.include?(requested) + warn "usage: script/package [darwin arm64|linux x86_64]" + exit 64 +end + +unless requested == host_target + warn "cross-packaging is unsupported; build #{requested['os']}/#{requested['arch']} on a matching native runner" + exit 64 +end + +artifact = File.join(ROOT, "dist", "hive_#{hive_version}_#{requested.fetch('os')}_#{requested.fetch('arch')}") +FileUtils.mkdir_p(File.dirname(artifact)) +payload = File.join(ROOT, "build", "tebako-payload") +FileUtils.rm_rf(payload) +FileUtils.mkdir_p(payload) +PAYLOAD_ENTRIES.each do |entry| + source = File.join(ROOT, entry) + raise "missing Tebako payload entry #{source}" unless File.exist?(source) + + FileUtils.cp_r(source, File.join(payload, entry), preserve: true) +end + +command = [ + "bundle", "exec", "tebako", "press", + "--root=#{payload}", + "--entry-point=#{File.join(payload, 'exe/hive')}", + "--output=#{artifact}", + "--Ruby=#{TEBAKO_RUBY_VERSION}", + "--mode=bundle" +] +command << "--patchelf" if requested.fetch("os") == "linux" + +cache_home = ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache")) +tebako_prefix = ENV.fetch("HIVE_TEBAKO_PREFIX", File.join(cache_home, "hive", "tebako")) +exec({ "TEBAKO_PREFIX" => tebako_prefix }, *command) diff --git a/script/release-manifest b/script/release-manifest new file mode 100755 index 00000000..340e03c9 --- /dev/null +++ b/script/release-manifest @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "optparse" + +ROOT = File.expand_path("..", __dir__) +$LOAD_PATH.unshift(File.join(ROOT, "lib")) +require "hive" +require "hive/release_manifest" + +options = { dist: File.join(ROOT, "dist") } +OptionParser.new do |parser| + parser.on("--dist PATH") { |value| options[:dist] = value } + parser.on("--version TAG") { |value| options[:version] = value } + parser.on("--base-url URL") { |value| options[:base_url] = value } +end.parse! + +abort "--version is required" unless options[:version] +Hive::ReleaseManifest.generate!( + dist: File.expand_path(options[:dist]), version: options[:version], release_base_url: options[:base_url] +) diff --git a/script/update-aur-pkgbuild b/script/update-aur-pkgbuild new file mode 100755 index 00000000..346d8308 --- /dev/null +++ b/script/update-aur-pkgbuild @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "open3" + +manifest_path = ARGV.fetch(0) { abort "usage: script/update-aur-pkgbuild RELEASE_MANIFEST.json" } +manifest = JSON.parse(File.read(manifest_path)) +target = manifest.fetch("targets").find { |entry| entry["os"] == "linux" && entry["arch"] == "x86_64" } +abort "manifest has no linux/x86_64 target" unless target +version = manifest.fetch("version").delete_prefix("v") +pkgbuild = File.expand_path(ARGV.fetch(1, "../packaging/aur/PKGBUILD"), __dir__) +srcinfo = File.expand_path(ARGV.fetch(2, File.join(File.dirname(pkgbuild), ".SRCINFO")), __dir__) +text = File.read(pkgbuild) +text = text.sub(/^pkgver=.*/, "pkgver=#{version}") +text = text.sub(%r{https://github\.com/ivankuznetsov/hive/releases/download/v[^/]+/hive_[^"]+}, target.fetch("url")) +text = text.sub(/sha256sums_x86_64=\('\w+'\)/, "sha256sums_x86_64=('#{target.fetch('sha256')}')") +text = text.gsub(/hive_(?:\$\{pkgver\}|\d+\.\d+\.\d+)_linux_x86_64/, target.fetch("name")) +File.write(pkgbuild, text) + +package_dir = File.dirname(pkgbuild) +command = if ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, "makepkg")) } + ["makepkg", "--printsrcinfo"] + else + [ + "docker", "run", "--rm", "-v", "#{package_dir}:/input:ro", "archlinux:base-devel", + "bash", "-lc", + "cp -a /input /tmp/pkg && useradd -m builder && chown -R builder:builder /tmp/pkg && " \ + "runuser -u builder -- bash -lc 'cd /tmp/pkg && makepkg --printsrcinfo'" + ] + end +output, error, status = Open3.capture3(*command, chdir: package_dir) +unless status.success? + abort "makepkg --printsrcinfo failed after updating PKGBUILD: #{error}" +end +File.write(srcinfo, output) diff --git a/script/update-homebrew-formula b/script/update-homebrew-formula new file mode 100755 index 00000000..6568262d --- /dev/null +++ b/script/update-homebrew-formula @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" + +manifest_path = ARGV.fetch(0) { abort "usage: script/update-homebrew-formula RELEASE_MANIFEST.json" } +manifest = JSON.parse(File.read(manifest_path)) +target = manifest.fetch("targets").find { |entry| entry["os"] == "darwin" && entry["arch"] == "arm64" } +abort "manifest has no darwin/arm64 target" unless target + +formula = File.expand_path(ARGV.fetch(1, "../packaging/homebrew/Formula/hive.rb"), __dir__) +text = File.read(formula) +text = text.sub(/version "[^"]+"/, "version \"#{manifest.fetch('version').delete_prefix('v')}\"") +text = text.sub(%r{url "[^"]+/hive_[^"]+"}, "url \"#{target.fetch('url')}\"") +text = text.sub(/sha256 "[a-f0-9]{64}"/, "sha256 \"#{target.fetch('sha256')}\"") +text = text.gsub(/hive_\d+\.\d+\.\d+_darwin_arm64/, target.fetch("name")) +File.write(formula, text) diff --git a/script/update-skills-metadata b/script/update-skills-metadata new file mode 100755 index 00000000..a30a91e4 --- /dev/null +++ b/script/update-skills-metadata @@ -0,0 +1,25 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" + +tag = ARGV.fetch(0) { abort "usage: script/update-skills-metadata vMAJOR.MINOR.PATCH [SKILLS_ROOT]" } +abort "version must be vMAJOR.MINOR.PATCH" unless tag.match?(/\Av\d+\.\d+\.\d+\z/) + +version = tag.delete_prefix("v") +root = File.expand_path(ARGV.fetch(1, "../packaging/hive-skills"), __dir__) +json_files = [ + File.join(root, ".codex-plugin", "plugin.json"), + File.join(root, "plugins", "hive", ".claude-plugin", "plugin.json"), + File.join(root, "package.json") +] +json_files.each do |path| + document = JSON.parse(File.read(path)) + document["version"] = version + File.write(path, JSON.pretty_generate(document) + "\n") +end + +Dir.glob(File.join(root, "**", "*.md"), File::FNM_DOTMATCH).each do |path| + text = File.read(path).gsub(/v\d+\.\d+\.\d+/, tag) + File.write(path, text) +end diff --git a/test/acceptance/arch_aur.sh b/test/acceptance/arch_aur.sh new file mode 100755 index 00000000..e8b74109 --- /dev/null +++ b/test/acceptance/arch_aur.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +candidate=${1:?usage: arch_aur.sh CANDIDATE_BINARY vVERSION} +tag=${2:?usage: arch_aur.sh CANDIDATE_BINARY vVERSION} +version=${tag#v} +tmp=$(mktemp -d) +trap 'pacman -R --noconfirm hive-bin >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT +pkg="$tmp/pkg" +mkdir -p "$pkg" "$tmp/home" "$tmp/fakebin" "$tmp/project" +cp "$candidate" "$pkg/hive_${version}_linux_x86_64" +cp packaging/aur/install-channel.yml "$pkg/install-channel.yml" +digest=$(sha256sum "$candidate" | awk '{print $1}') +marker_digest=$(sha256sum packaging/aur/install-channel.yml | awk '{print $1}') +cat > "$pkg/PKGBUILD" < "$tmp/fakebin/systemctl" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG" +exit 0 +SH +cat > "$tmp/fakebin/yay" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_PACKAGE_LOG" +exit 0 +SH +chmod +x "$tmp/fakebin/systemctl" "$tmp/fakebin/yay" +export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/systemctl.log" +export HIVE_ACCEPTANCE_PACKAGE_LOG="$tmp/yay.log" +export PATH="$tmp/fakebin:$PATH" + +git -C "$tmp/project" init -b main --quiet +git -C "$tmp/project" config user.email acceptance@example.com +git -C "$tmp/project" config user.name Acceptance +printf 'seed\n' > "$tmp/project/README.md" +git -C "$tmp/project" add README.md +git -C "$tmp/project" commit -m seed --quiet +/usr/bin/hive init "$tmp/project" +unit="$XDG_CONFIG_HOME/systemd/user/hive.service" +test -f "$unit" +test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG" +/usr/bin/hive daemon start +grep -F 'enable --now hive.service' "$HIVE_ACCEPTANCE_SERVICE_LOG" +before_update=$(sha256sum /usr/bin/hive | awk '{print $1}') +/usr/bin/hive update +test "$(sha256sum /usr/bin/hive | awk '{print $1}')" = "$before_update" +grep -F -- '-Syu hive-bin' "$HIVE_ACCEPTANCE_PACKAGE_LOG" +printf 'operator edit\n' > "$tmp/project/.hive/README.md" +printf 'completed output\n' > "$tmp/project/.hive/operator-output.txt" +/usr/bin/hive uninstall "$tmp/project" --purge +grep -F -- '-Rns hive-bin' "$HIVE_ACCEPTANCE_PACKAGE_LOG" +test -f "$tmp/project/.hive/README.md" +test -f "$tmp/project/.hive/operator-output.txt" +test ! -e "$unit" +printf 'arch aur acceptance artifact sha256=%s\n' "$digest" diff --git a/test/acceptance/macos_homebrew.sh b/test/acceptance/macos_homebrew.sh new file mode 100755 index 00000000..0be18e57 --- /dev/null +++ b/test/acceptance/macos_homebrew.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +candidate=${1:?usage: macos_homebrew.sh CANDIDATE_BINARY vVERSION} +tag=${2:?usage: macos_homebrew.sh CANDIDATE_BINARY vVERSION} +version=${tag#v} +command -v brew >/dev/null 2>&1 +tmp=$(mktemp -d) +real_brew=$(command -v brew) +trap '"$real_brew" uninstall --force hive-acceptance >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT + +artifact="$tmp/hive_${version}_darwin_arm64" +cp "$candidate" "$artifact" +chmod +x "$artifact" +digest=$(shasum -a 256 "$artifact" | awk '{print $1}') +formula="$tmp/hive_acceptance.rb" +cat > "$formula" < "hive" + bin.install_symlink "hive" => "hv" + (share/"hive/install-channel.yml").write <<~YAML + channel: homebrew + package_id: ivankuznetsov/hive/hive + YAML + end +end +RUBY +"$real_brew" install --formula "$formula" +! "$real_brew" deps --tree hive-acceptance | grep -qi ruby + +export HOME="$tmp/home" +export HIVE_HOME="$tmp/hive-home" +export XDG_CONFIG_HOME="$tmp/config" +export XDG_DATA_HOME="$tmp/data" +export XDG_STATE_HOME="$tmp/state" +export XDG_CACHE_HOME="$tmp/cache" +mkdir -p "$HOME" "$tmp/fakebin" "$tmp/project" +command_path="$("$real_brew" --prefix)/bin/hive" +test "$("$command_path" --version)" = "$version" +grep -F 'channel: homebrew' "$XDG_STATE_HOME/hive/install.yml" + +cat > "$tmp/fakebin/launchctl" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG" +exit 0 +SH +cat > "$tmp/fakebin/brew" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_PACKAGE_LOG" +exit 0 +SH +chmod +x "$tmp/fakebin/launchctl" "$tmp/fakebin/brew" +export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/launchctl.log" +export HIVE_ACCEPTANCE_PACKAGE_LOG="$tmp/brew.log" +export PATH="$tmp/fakebin:$PATH" + +git -C "$tmp/project" init -b main --quiet +git -C "$tmp/project" config user.email acceptance@example.com +git -C "$tmp/project" config user.name Acceptance +printf 'seed\n' > "$tmp/project/README.md" +git -C "$tmp/project" add README.md +git -C "$tmp/project" commit -m seed --quiet +"$command_path" init "$tmp/project" +plist="$HOME/Library/LaunchAgents/dev.hive.daemon.plist" +grep -F 'RunAtLoad' "$plist" +grep -F 'KeepAlive' "$plist" +test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG" +"$command_path" daemon start +grep -F 'kickstart -k' "$HIVE_ACCEPTANCE_SERVICE_LOG" + +before_update=$(shasum -a 256 "$command_path" | awk '{print $1}') +"$command_path" update +test "$(shasum -a 256 "$command_path" | awk '{print $1}')" = "$before_update" +grep -F 'upgrade ivankuznetsov/hive/hive' "$HIVE_ACCEPTANCE_PACKAGE_LOG" +printf 'operator edit\n' > "$tmp/project/.hive/README.md" +printf 'completed output\n' > "$tmp/project/.hive/operator-output.txt" +"$command_path" uninstall "$tmp/project" --purge +grep -F 'uninstall ivankuznetsov/hive/hive' "$HIVE_ACCEPTANCE_PACKAGE_LOG" +test -f "$tmp/project/.hive/README.md" +test -f "$tmp/project/.hive/operator-output.txt" +test ! -e "$plist" +printf 'macos homebrew acceptance artifact sha256=%s\n' "$digest" diff --git a/test/acceptance/prompt_transcripts.yml b/test/acceptance/prompt_transcripts.yml new file mode 100644 index 00000000..4b910113 --- /dev/null +++ b/test/acceptance/prompt_transcripts.yml @@ -0,0 +1,17 @@ +transcripts: + - host: claude + os: macos-arm64 + channel: homebrew + verifies: hive --version + - host: codex + os: arch-x86_64 + channel: aur + verifies: hive --version + - host: pi + os: ubuntu-x86_64 + channel: bash + verifies: hive --version + - host: any + os: collision + channel: bash-hv + verifies: hv --version diff --git a/test/acceptance/release_gate_test.rb b/test/acceptance/release_gate_test.rb new file mode 100644 index 00000000..27b5f803 --- /dev/null +++ b/test/acceptance/release_gate_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class ReleaseGateTest < Minitest::Test + def test_release_publication_is_least_privilege_and_acceptance_blocked + workflow = File.read(".github/workflows/release.yml") + + assert_match(/permissions:\n contents: read/, workflow) + assert_match(/publish:\n.*?permissions:\n contents: write\n id-token: write\n attestations: write/m, workflow) + assert_match(/needs: \[test-and-security, build-linux, build-macos, tier1-acceptance\]/, workflow) + assert_includes workflow, "bundle exec rake test" + assert_includes workflow, "bundle exec brakeman" + assert_includes workflow, "bundle exec bundler-audit" + end + + def test_downstream_publication_updates_all_versioned_metadata_from_the_manifest + workflow = File.read(".github/workflows/release.yml") + + assert_includes workflow, "script/update-homebrew-formula dist/release-manifest.json" + assert_includes workflow, "script/update-aur-pkgbuild dist/release-manifest.json" + assert_includes workflow, "script/update-skills-metadata" + assert_includes workflow, "git -C \"downstream/$repository\" push" + assert_includes File.read("script/update-aur-pkgbuild"), "makepkg" + assert_includes File.read("script/update-aur-pkgbuild"), ".SRCINFO" + end + + def test_tier_one_jobs_are_non_skippable_and_run_full_lifecycle_contracts + workflow = File.read(".github/workflows/tier1-acceptance.yml") + refute_match(/^\s+if:/, workflow) + assert_includes workflow, "macos-homebrew" + assert_includes workflow, "ubuntu-bash" + assert_includes workflow, "arch-aur" + assert_includes workflow, "host: [claude, codex, pi]" + + %w[ubuntu_bash.sh macos_homebrew.sh arch_aur.sh].each do |script| + content = File.read(File.join("test/acceptance", script)) + %w[init daemon update uninstall sha256].each do |operation| + assert_includes content, operation, "#{script} must exercise #{operation}" + end + end + end +end diff --git a/test/acceptance/ubuntu_bash.sh b/test/acceptance/ubuntu_bash.sh new file mode 100755 index 00000000..aac8bf26 --- /dev/null +++ b/test/acceptance/ubuntu_bash.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +candidate=${1:?usage: ubuntu_bash.sh CANDIDATE_BINARY vVERSION} +tag=${2:?usage: ubuntu_bash.sh CANDIDATE_BINARY vVERSION} +version=${tag#v} +root=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +release="$tmp/release" +fakebin="$tmp/fakebin" +home="$tmp/home" +project="$tmp/project" +mkdir -p "$release" "$fakebin" "$home" "$project" +target="hive_${version}_linux_x86_64" +cp "$candidate" "$release/$target" +cp "$root/install.sh" "$release/install.sh" +chmod +x "$release/$target" +digest=$(sha256sum "$release/$target" | awk '{print $1}') +printf '{"version":"%s","targets":[{"name":"%s","sha256":"%s"}]}\n' "$tag" "$target" "$digest" > "$release/release-manifest.json" + +cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +output= +url= +while (($#)); do + case "$1" in + -o) output=$2; shift 2 ;; + -*) shift ;; + *) url=$1; shift ;; + esac +done +source_file="$HIVE_ACCEPTANCE_RELEASE/${url##*/}" +test -f "$source_file" +if [[ -n $output ]]; then cp "$source_file" "$output"; else cat "$source_file"; fi +SH +cat > "$fakebin/systemctl" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG" +exit 0 +SH +chmod +x "$fakebin/curl" "$fakebin/systemctl" + +export HOME="$home" +export HIVE_HOME="$tmp/hive-home" +export XDG_CONFIG_HOME="$tmp/config" +export XDG_DATA_HOME="$tmp/data" +export XDG_STATE_HOME="$tmp/state" +export XDG_CACHE_HOME="$tmp/cache" +export HIVE_BIN_DIR="$tmp/bin" +export HIVE_VERSION="$tag" +export HIVE_RELEASE_BASE_URL="file://$release" +export HIVE_ACCEPTANCE_RELEASE="$release" +export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/systemctl.log" +export PATH="$fakebin:$PATH" + +bash "$root/install.sh" +command_path="$HIVE_BIN_DIR/hive" +test "$("$command_path" --version)" = "$version" +test "$(sha256sum "$command_path" | awk '{print $1}')" = "$digest" + +git -C "$project" init -b main --quiet +git -C "$project" config user.email acceptance@example.com +git -C "$project" config user.name Acceptance +printf 'seed\n' > "$project/README.md" +git -C "$project" add README.md +git -C "$project" commit -m seed --quiet +"$command_path" init "$project" +unit="$XDG_CONFIG_HOME/systemd/user/hive.service" +test -f "$unit" +grep -F 'XDG_CONFIG_HOME' "$unit" +test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG" + +"$command_path" daemon start +grep -F 'enable --now hive.service' "$HIVE_ACCEPTANCE_SERVICE_LOG" +before_update=$(sha256sum "$command_path" | awk '{print $1}') +"$command_path" update +test "$(sha256sum "$command_path" | awk '{print $1}')" = "$before_update" + +printf 'operator edit\n' > "$project/.hive/README.md" +printf 'completed output\n' > "$project/.hive/operator-output.txt" +printf 'durable state\n' > "$XDG_STATE_HOME/hive/operator-state" +printf 'legacy state\n' > "$project/.hive-state/operator-state" +"$command_path" uninstall "$project" --purge + +test -f "$project/.hive/README.md" +test -f "$project/.hive/operator-output.txt" +test -f "$project/.hive-state/operator-state" +test -f "$XDG_STATE_HOME/hive/operator-state" +test -f "$XDG_CONFIG_HOME/hive/config.yml" +test ! -e "$XDG_DATA_HOME/hive/current" +test ! -e "$XDG_DATA_HOME/hive/releases" +test ! -e "$unit" +for _attempt in 1 2 3 4 5; do + [[ ! -e $command_path ]] && break + sleep 1 +done +test ! -e "$command_path" +printf 'ubuntu bash acceptance artifact sha256=%s\n' "$digest" diff --git a/test/acceptance/uninstall_preserves_work.sh b/test/acceptance/uninstall_preserves_work.sh new file mode 100755 index 00000000..bf1a65af --- /dev/null +++ b/test/acceptance/uninstall_preserves_work.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +bundle exec ruby -I"$root/test" "$root/test/unit/commands/uninstall_test.rb" diff --git a/test/acceptance_contract_test.rb b/test/acceptance_contract_test.rb new file mode 100644 index 00000000..638dcaad --- /dev/null +++ b/test/acceptance_contract_test.rb @@ -0,0 +1,13 @@ +require "test_helper" +require "yaml" + +class AcceptanceContractTest < Minitest::Test + def test_curated_matrix_covers_every_tier_one_channel_and_collision_safe_command + matrix = YAML.safe_load(File.read("test/acceptance/prompt_transcripts.yml")).fetch("transcripts") + assert_equal %w[aur bash bash-hv homebrew], matrix.map { |row| row.fetch("channel") }.sort + assert File.executable?("test/acceptance/ubuntu_bash.sh") + assert File.executable?("test/acceptance/macos_homebrew.sh") + assert File.executable?("test/acceptance/arch_aur.sh") + assert File.executable?("test/acceptance/uninstall_preserves_work.sh") + end +end diff --git a/test/install/install_failure.sh b/test/install/install_failure.sh new file mode 100755 index 00000000..4d620c3d --- /dev/null +++ b/test/install/install_failure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +bin="$tmp/bin" +mkdir -p "$bin" +printf '#!/usr/bin/env bash\nprintf "Apache Hive 4.0\\n"\n' > "$bin/hive" +chmod +x "$bin/hive" +release="$tmp/release" +mkdir "$release" +artifact="$release/hive_0.1.0_linux_x86_64" +printf '#!/usr/bin/env bash\nprintf "0.1.0\\n"\n' > "$artifact" +chmod +x "$artifact" +digest=$(sha256sum "$artifact" | awk '{print $1}') +printf '{"version":"v0.1.0","targets":[{"name":"hive_0.1.0_linux_x86_64","sha256":"%s"}]}' "$digest" > "$release/release-manifest.json" + +HOME="$tmp/home" XDG_STATE_HOME="$tmp/state" HIVE_BIN_DIR="$bin" HIVE_VERSION=v0.1.0 \ + HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh" +test "$("$bin/hive" --version)" = "Apache Hive 4.0" +test "$("$bin/hv" --version)" = "0.1.0" +grep -F 'command_name: hv' "$tmp/state/hive/install.yml" + +path_bin="$tmp/path-bin" +isolated_bin="$tmp/isolated-bin" +mkdir -p "$path_bin" "$isolated_bin" +printf '#!/usr/bin/env bash\nprintf "Apache Hive elsewhere\\n"\n' > "$path_bin/hive" +chmod +x "$path_bin/hive" +PATH="$path_bin:$PATH" HOME="$tmp/home-path" XDG_STATE_HOME="$tmp/state-path" HIVE_BIN_DIR="$isolated_bin" HIVE_VERSION=v0.1.0 \ + HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh" +test ! -e "$isolated_bin/hive" +test "$($isolated_bin/hv --version)" = "0.1.0" +test "$($path_bin/hive --version)" = "Apache Hive elsewhere" + +preserve_bin="$tmp/preserve-bin" +mkdir -p "$preserve_bin" +printf '#!/usr/bin/env bash\nprintf "foreign hv\\n"\n' > "$preserve_bin/hv" +chmod +x "$preserve_bin/hv" +HOME="$tmp/home-preserve" XDG_STATE_HOME="$tmp/state-preserve" HIVE_BIN_DIR="$preserve_bin" HIVE_VERSION=v0.1.0 \ + HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh" +test "$($preserve_bin/hive --version)" = "0.1.0" +test "$($preserve_bin/hv --version)" = "foreign hv" + +rollback_bin="$tmp/rollback-bin" +rollback_state="$tmp/rollback-state" +mkdir -p "$rollback_bin" "$rollback_state/hive/install.yml" +if HOME="$tmp/home-rollback" XDG_STATE_HOME="$rollback_state" HIVE_BIN_DIR="$rollback_bin" HIVE_VERSION=v0.1.0 \ + HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"; then + echo "installer unexpectedly succeeded with an unreplaceable receipt" >&2 + exit 1 +fi +test ! -e "$rollback_bin/hive" +test ! -e "$rollback_bin/hv" +test -d "$rollback_state/hive/install.yml" + +locked_bin="$tmp/locked-bin" +locked_state="$tmp/locked-state" +mkdir -p "$locked_bin" "$locked_state/hive/install.yml.lock" +if HOME="$tmp/home-locked" XDG_STATE_HOME="$locked_state" HIVE_BIN_DIR="$locked_bin" HIVE_VERSION=v0.1.0 \ + HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"; then + echo "installer unexpectedly bypassed an active install lock" >&2 + exit 1 +fi +test -d "$locked_state/hive/install.yml.lock" +test ! -e "$locked_bin/hive" +test ! -e "$locked_bin/hv" diff --git a/test/install/install_success.sh b/test/install/install_success.sh new file mode 100755 index 00000000..525e4120 --- /dev/null +++ b/test/install/install_success.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +release="$tmp/release" +bin="$tmp/bin" +mkdir -p "$release" "$bin" +artifact="$release/hive_0.1.0_linux_x86_64" +printf '#!/usr/bin/env bash\nprintf "0.1.0\\n"\n' > "$artifact" +chmod +x "$artifact" +digest=$(sha256sum "$artifact" | awk '{print $1}') +cat > "$release/release-manifest.json" < home) + env = ENV.to_h.merge( + "HIVE_HOME" => home, + "XDG_STATE_HOME" => File.join(home, "state"), + "XDG_CACHE_HOME" => File.join(home, "cache") + ) + FileUtils.mkdir_p(File.dirname(pid_file(home))) block.call(home, env) end end + def pid_file(home) + File.join(home, "state", "hive", "daemon.pid") + end + # ── status: not running by default ──────────────────────────────────── def test_status_returns_not_running_with_exit_1_when_no_daemon @@ -37,7 +46,7 @@ class HiveDaemonCommandTest < Minitest::Test doc = JSON.parse(out) assert_equal "hive-daemon-status", doc["schema"] assert_equal false, doc["running"] - assert_equal File.join(home, ".daemon.pid"), doc["pid_file"] + assert_equal pid_file(home), doc["pid_file"] assert_equal 1, status.exitstatus end end @@ -56,11 +65,11 @@ class HiveDaemonCommandTest < Minitest::Test with_isolated_hive_home do |home, env| # Bare-integer PID format (back-compat path for older daemon # versions) pointing at a process that doesn't exist. - File.write(File.join(home, ".daemon.pid"), "999999") + File.write(pid_file(home), "999999") _out, err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop") assert_equal 0, status.exitstatus assert_match(/stale/, err) - refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file must be removed" + refute File.exist?(pid_file(home)), "stale PID file must be removed" end end @@ -71,7 +80,7 @@ class HiveDaemonCommandTest < Minitest::Test # file at all". def test_stop_with_malformed_pid_file_emits_envelope_and_cleans_up with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), "this is not a YAML PID payload\n") + File.write(pid_file(home), "this is not a YAML PID payload\n") out, _err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop", "--json") assert_equal 0, status.exitstatus doc = JSON.parse(out) @@ -81,7 +90,7 @@ class HiveDaemonCommandTest < Minitest::Test assert_equal false, doc["was_running"] assert_equal "malformed_pid_file", doc["reason"], "malformed PID file must surface the closed reason enum value" - refute File.exist?(File.join(home, ".daemon.pid")), + refute File.exist?(pid_file(home)), "malformed PID file must be cleaned up" end end @@ -91,7 +100,7 @@ class HiveDaemonCommandTest < Minitest::Test # process_start_time + started_at). A YAML payload pointing at a # dead PID is the same scenario as the bare-int legacy path. with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: 999999 process_start_time: "1234567890" @@ -100,7 +109,7 @@ class HiveDaemonCommandTest < Minitest::Test _out, err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop") assert_equal 0, status.exitstatus assert_match(/stale|not alive/, err) - refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file must be removed" + refute File.exist?(pid_file(home)), "stale PID file must be removed" end end @@ -110,7 +119,7 @@ class HiveDaemonCommandTest < Minitest::Test # start_time → another process took over the PID. Don't TERM it. # Use the test runner's own PID with a fake/wrong start_time. with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: #{Process.pid} process_start_time: "definitely-not-the-real-start-time-#{rand(100_000)}" @@ -122,7 +131,7 @@ class HiveDaemonCommandTest < Minitest::Test # Test process is still alive — we never sent it SIGTERM. assert Process.kill(0, Process.pid), "the test runner process must NOT have received SIGTERM" - refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file removed" + refute File.exist?(pid_file(home)), "stale PID file removed" end end @@ -134,7 +143,7 @@ class HiveDaemonCommandTest < Minitest::Test # the PID rather than risk hitting an unrelated process that was # handed the same PID after reuse. Test process must NOT be TERMed. with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: #{Process.pid} process_start_time: @@ -146,7 +155,7 @@ class HiveDaemonCommandTest < Minitest::Test assert Process.kill(0, Process.pid), "test process must NOT have been signaled" # Unverified does NOT remove the PID file — operator must # confirm + clean up manually per the warn message. - assert File.exist?(File.join(home, ".daemon.pid")), + assert File.exist?(pid_file(home)), "unverified ownership leaves the PID file in place for operator inspection" end end @@ -173,7 +182,7 @@ class HiveDaemonCommandTest < Minitest::Test def test_reload_json_envelope_when_pid_dead with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: 999999 process_start_time: "1234567890" @@ -191,7 +200,7 @@ class HiveDaemonCommandTest < Minitest::Test def test_reload_json_envelope_when_pid_reused with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: #{Process.pid} process_start_time: "definitely-not-the-real-start-time-#{rand(100_000)}" @@ -208,7 +217,7 @@ class HiveDaemonCommandTest < Minitest::Test def test_reload_json_envelope_when_pid_unverified with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: #{Process.pid} process_start_time: @@ -235,7 +244,7 @@ class HiveDaemonCommandTest < Minitest::Test def test_reload_refuses_when_pid_ownership_unverified with_isolated_hive_home do |home, env| - File.write(File.join(home, ".daemon.pid"), <<~YAML) + File.write(pid_file(home), <<~YAML) --- pid: #{Process.pid} process_start_time: diff --git a/test/integration/init_distribution_test.rb b/test/integration/init_distribution_test.rb new file mode 100644 index 00000000..384b815c --- /dev/null +++ b/test/integration/init_distribution_test.rb @@ -0,0 +1,84 @@ +require "test_helper" +require "hive/commands/init" +require "hive/paths" + +class InitDistributionTest < Minitest::Test + include HiveTestHelper + + def test_init_adds_xdg_assets_and_is_idempotent + with_tmp_global_config do |home| + with_tmp_git_repo do |project| + paths = Hive::Paths.new(env: {}, home: home) + init = Hive::Commands::Init.new(project, paths: paths, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY") + capture_io { init.call } + + assert File.directory?(File.join(project, ".hive")) + assert_equal "anthropic", YAML.safe_load(File.read(paths.config_file)).fetch("provider") + scaffold = File.join(project, ".hive", "README.md") + File.write(scaffold, "custom work\n") + + capture_io { init.call } + assert_equal "custom work\n", File.read(scaffold) + end + end + end + + def test_init_emits_actionable_dependency_diagnostics_without_auto_installing + with_tmp_global_config do |home| + with_tmp_git_repo do |project| + diagnostics = %w[git bash claude gh jq].map do |tool| + Hive::Dependencies::Diagnostic.new(tool, "install #{tool} yourself", true) + end + dependencies = Object.new + dependencies.define_singleton_method(:diagnostics_for) { |action| action == :init ? diagnostics : [] } + dependency_output = StringIO.new + paths = Hive::Paths.new(env: {}, home: home) + + capture_io do + Hive::Commands::Init.new( + project, paths: paths, dependencies: dependencies, dependency_output: dependency_output + ).call + end + + %w[git bash claude gh jq].each do |tool| + assert_includes dependency_output.string, "install #{tool} yourself" + end + end + end + end + + def test_registered_service_start_prompt_defaults_no_and_requires_explicit_yes + with_tmp_global_config do |home| + with_tmp_git_repo do |project| + paths = Hive::Paths.new(env: {}, home: home) + Hive::Install::Receipt.new( + channel: "bash", package_id: "hive", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", + installed_version: Hive::VERSION, executable_path: File.join(home, "bin", "hive"), command_name: "hive" + ).write!(paths) + starts = 0 + registrations = 0 + manager = Object.new + manager.define_singleton_method(:register!) { registrations += 1 } + manager.define_singleton_method(:start!) { starts += 1 } + input = StringIO.new("\ny\n") + input.define_singleton_method(:tty?) { true } + output = StringIO.new + dependencies = Object.new + dependencies.define_singleton_method(:diagnostics_for) { |_action| [] } + init = Hive::Commands::Init.new( + project, paths: paths, service_manager: manager, dependencies: dependencies, + service_input: input, service_output: output + ) + + capture_io { init.call } + assert_equal 0, starts + assert_includes output.string, "[y/N]" + + capture_io { init.call } + assert_equal 1, starts + assert_equal 2, registrations + end + end + end +end diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb index 0b423861..5f4eca5c 100644 --- a/test/integration/init_test.rb +++ b/test/integration/init_test.rb @@ -113,14 +113,14 @@ class InitTest < Minitest::Test end end - def test_double_init_raises_already_initialized_with_exit_2 + def test_double_init_is_idempotent_and_keeps_the_existing_project_state with_tmp_global_config do with_tmp_git_repo do |dir| capture_io { Hive::Commands::Init.new(dir).call } - _, err, status = with_captured_exit { Hive::Commands::Init.new(dir).call } - assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status, - "second init must raise Hive::AlreadyInitialized (exit 2), not bare exit" - assert_includes err, "already initialized" + out, _err = capture_io { Hive::Commands::Init.new(dir).call } + assert_includes out, "hive: initialized" + assert File.directory?(File.join(dir, ".hive-state")) + assert File.directory?(File.join(dir, ".hive")) end end end @@ -287,11 +287,10 @@ class InitTest < Minitest::Test end end - def test_init_already_initialized_short_circuits_before_any_prompt - # On a re-run of `hive init` the AlreadyInitialized guard must fire - # BEFORE the prompt module reads anything from stdin. We feed an - # input stream that would crash the prompt validator if consumed - # ('crash'), then assert it's still pristine after the second init. + def test_idempotent_init_short_circuits_before_any_prompt + # A re-run refreshes only manifest-owned assets and must not reopen the + # original onboarding prompts. We feed input that would crash the prompt + # validator if consumed, then assert it remains pristine. with_tmp_global_config do with_tmp_git_repo do |dir| capture_io { Hive::Commands::Init.new(dir).call } @@ -301,11 +300,10 @@ class InitTest < Minitest::Test input.define_singleton_method(:tty?) { true } prompts = Hive::Commands::Init::Prompts.new(input: input, output: StringIO.new) - _, err, status = with_captured_exit do + _out, _err, status = with_captured_exit do Hive::Commands::Init.new(dir, prompts: prompts).call end - assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status - assert_includes err, "already initialized" + assert_equal Hive::ExitCodes::SUCCESS, status assert_equal "crash-on-this-input", input.gets&.chomp, "no input should have been consumed by the second init" end diff --git a/test/packaging/aur_clean_chroot.sh b/test/packaging/aur_clean_chroot.sh new file mode 100755 index 00000000..60c4ff7b --- /dev/null +++ b/test/packaging/aur_clean_chroot.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +command -v makechrootpkg >/dev/null 2>&1 || { echo 'makechrootpkg is required on an Arch build host' >&2; exit 77; } +command -v namcap >/dev/null 2>&1 || { echo 'namcap is required on an Arch build host' >&2; exit 77; } +cd "$root/packaging/aur" +makechrootpkg -r "${HIVE_ARCH_CHROOT:-$HOME/chroot/root}" -- -f +namcap ./*.pkg.tar.* diff --git a/test/packaging/aur_pkgbuild_test.rb b/test/packaging/aur_pkgbuild_test.rb new file mode 100644 index 00000000..3c6c3174 --- /dev/null +++ b/test/packaging/aur_pkgbuild_test.rb @@ -0,0 +1,51 @@ +require "test_helper" +require "json" +require "open3" + +class AurPkgbuildTest < Minitest::Test + def test_prebuilt_package_uses_canonical_linux_binary_and_installs_alias + pkgbuild = File.read("packaging/aur/PKGBUILD") + assert_includes pkgbuild, "pkgname=hive-bin" + assert_includes pkgbuild, "hive_\${pkgver}_linux_x86_64" + assert_match(/sha256sums_x86_64=\('[a-f0-9]{64}'\)/, pkgbuild) + assert_includes pkgbuild, 'ln -s hive "$pkgdir/usr/bin/hv"' + assert_includes pkgbuild, "/usr/share/hive/install-channel.yml" + assert File.file?("packaging/aur/install-channel.yml") + refute_match(/ruby/, pkgbuild) + end + + def test_metadata_updater_regenerates_srcinfo_with_makepkg + Dir.mktmpdir("aur-update") do |root| + packaging = File.join(root, "packaging") + bin = File.join(root, "bin") + FileUtils.mkdir_p(packaging) + FileUtils.mkdir_p(bin) + FileUtils.cp("packaging/aur/PKGBUILD", File.join(packaging, "PKGBUILD")) + FileUtils.cp("packaging/aur/install-channel.yml", File.join(packaging, "install-channel.yml")) + manifest = File.join(root, "manifest.json") + File.write(manifest, JSON.generate( + "version" => "v1.2.3", + "targets" => [ + { + "os" => "linux", "arch" => "x86_64", "name" => "hive_1.2.3_linux_x86_64", + "url" => "https://github.com/ivankuznetsov/hive/releases/download/v1.2.3/hive_1.2.3_linux_x86_64", + "sha256" => "a" * 64 + } + ] + )) + makepkg = File.join(bin, "makepkg") + File.write(makepkg, "#!/usr/bin/env bash\nprintf 'pkgbase = hive-bin\\n\\tpkgver = 1.2.3\\n'\n") + File.chmod(0o755, makepkg) + srcinfo = File.join(packaging, ".SRCINFO") + + _out, err, status = Open3.capture3( + { "PATH" => "#{bin}:#{ENV.fetch('PATH')}" }, + "script/update-aur-pkgbuild", manifest, File.join(packaging, "PKGBUILD"), srcinfo + ) + + assert status.success?, err + assert_includes File.read(srcinfo), "pkgver = 1.2.3" + assert_includes File.read(File.join(packaging, "PKGBUILD")), "pkgver=1.2.3" + end + end +end diff --git a/test/packaging/hive_skills_test.rb b/test/packaging/hive_skills_test.rb new file mode 100644 index 00000000..9e381e75 --- /dev/null +++ b/test/packaging/hive_skills_test.rb @@ -0,0 +1,40 @@ +require "test_helper" +require "json" +require "open3" + +class HiveSkillsTest < Minitest::Test + ROOT = "packaging/hive-skills".freeze + + def test_host_manifests_are_parseable_and_point_to_the_canonical_skill + codex = JSON.parse(File.read(File.join(ROOT, ".codex-plugin/plugin.json"))) + marketplace = JSON.parse(File.read(File.join(ROOT, ".agents/plugins/marketplace.json"))) + pi = JSON.parse(File.read(File.join(ROOT, "package.json"))) + + assert_equal "hive", codex.fetch("name") + assert_equal "0.1.0", codex.fetch("version") + assert_equal "hive", marketplace.fetch("plugins").first.fetch("name") + assert_equal "./plugins/hive", marketplace.fetch("plugins").first.dig("source", "path") + assert_equal [ "skills/hive" ], pi.dig("pi", "skills") + assert File.file?(File.join(ROOT, "skills/hive/SKILL.md")) + end + + def test_core_installer_does_not_mutate_agent_owned_directories + installer = File.read("install.sh") + %w[.claude .codex .pi .agents].each { |directory| refute_includes installer, directory } + end + + def test_release_updater_keeps_host_metadata_on_one_version + Dir.mktmpdir("hive-skills-update") do |root| + copy = File.join(root, "skills") + FileUtils.cp_r(ROOT, copy) + + _out, err, status = Open3.capture3("script/update-skills-metadata", "v1.2.3", copy) + + assert status.success?, err + assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, "package.json"))).fetch("version") + assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, ".codex-plugin/plugin.json"))).fetch("version") + assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, "plugins/hive/.claude-plugin/plugin.json"))).fetch("version") + assert_includes File.read(File.join(copy, "README.md")), "v1.2.3" + end + end +end diff --git a/test/packaging/homebrew_formula_test.rb b/test/packaging/homebrew_formula_test.rb new file mode 100644 index 00000000..ac32553e --- /dev/null +++ b/test/packaging/homebrew_formula_test.rb @@ -0,0 +1,14 @@ +require "test_helper" +require "json" + +class HomebrewFormulaTest < Minitest::Test + def test_formula_is_pinned_to_the_canonical_macos_artifact_shape + formula = File.read("packaging/homebrew/Formula/hive.rb") + assert_includes formula, "hive_0.1.0_darwin_arm64" + assert_match(/sha256 "[a-f0-9]{64}"/, formula) + assert_includes formula, 'bin.install_symlink "hive" => "hv"' + assert_includes formula, "install-channel.yml" + assert_includes formula, "channel: homebrew" + refute_match(/depends_on .*ruby/i, formula) + end +end diff --git a/test/prompt/evaluate_install_prompt.rb b/test/prompt/evaluate_install_prompt.rb new file mode 100644 index 00000000..5e830927 --- /dev/null +++ b/test/prompt/evaluate_install_prompt.rb @@ -0,0 +1,31 @@ +require "test_helper" +require "yaml" + +class EvaluateInstallPromptTest < Minitest::Test + def test_the_published_prompt_covers_every_routing_and_safety_branch + prompt = File.read("docs/install-prompt.md") + cases = YAML.safe_load(File.read("test/prompt/install_cases.yml")).fetch("cases") + + cases.each do |fixture| + fixture.fetch("required").each do |phrase| + assert_includes prompt, phrase, "#{fixture.fetch('name')} requires #{phrase.inspect}" + end + end + end + + def test_requested_host_has_an_executable_install_and_remove_route + host = ENV["HIVE_PROMPT_HOST"] + skip "HIVE_PROMPT_HOST is only set by the tier-1 host matrix" unless host + + prompt = File.read("docs/install-prompt.md") + routes = { + "claude" => [ "claude plugin install hive@hive-skills", "claude plugin uninstall hive@hive-skills" ], + "codex" => [ "codex plugin add hive@hive-skills", "codex plugin remove hive@hive-skills" ], + "pi" => [ + "pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0", + "pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0" + ] + } + routes.fetch(host).each { |command| assert_includes prompt, command } + end +end diff --git a/test/prompt/install_cases.yml b/test/prompt/install_cases.yml new file mode 100644 index 00000000..b3392071 --- /dev/null +++ b/test/prompt/install_cases.yml @@ -0,0 +1,11 @@ +cases: + - name: claude-macos + required: ["macOS arm64", "brew install", "claude plugin install hive@hive-skills", "claude plugin uninstall hive@hive-skills"] + - name: codex-arch + required: ["Arch x86_64", "hive-bin", "codex plugin marketplace add", "codex plugin add hive@hive-skills", "codex plugin remove hive@hive-skills"] + - name: pi-ubuntu + required: ["Ubuntu", "checksum-verifying installer", "pi install git:", "pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0"] + - name: hive-collision + required: ["install only `hv`", "leave hive untouched", "HIVE_COMMAND", "$HIVE_COMMAND init"] + - name: denied-or-unavailable + required: ["do not change the host", "never copy skill files manually"] diff --git a/test/release/checksums.sh b/test/release/checksums.sh new file mode 100755 index 00000000..72652d28 --- /dev/null +++ b/test/release/checksums.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +dist=${1:?usage: test/release/checksums.sh DIST} +cd "$dist" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check SHA256SUMS +else + shasum -a 256 --check SHA256SUMS +fi diff --git a/test/release/manifest_test.rb b/test/release/manifest_test.rb new file mode 100644 index 00000000..d7413b72 --- /dev/null +++ b/test/release/manifest_test.rb @@ -0,0 +1,52 @@ +require "test_helper" +require "json" +require "open3" + +class ReleaseManifestTest < Minitest::Test + include HiveTestHelper + + def test_generates_complete_canonical_manifest_and_checksums + with_tmp_dir do |dist| + %W[hive_#{Hive::VERSION}_darwin_arm64 hive_#{Hive::VERSION}_linux_x86_64 install.sh].each do |name| + File.write(File.join(dist, name), name) + end + + _out, err, status = Open3.capture3("script/release-manifest", "--dist", dist, "--version", "v#{Hive::VERSION}") + + assert status.success?, err + manifest = JSON.parse(File.read(File.join(dist, "release-manifest.json"))) + assert_equal "v#{Hive::VERSION}", manifest.fetch("version") + assert_equal 2, manifest.fetch("targets").size + assert_equal "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", manifest.fetch("release_base_url") + assert File.file?(File.join(dist, "SHA256SUMS")) + end + end + + def test_rejects_non_semver_tags + with_tmp_dir do |dist| + _out, err, status = Open3.capture3("script/release-manifest", "--dist", dist, "--version", "nightly") + + refute status.success? + assert_match(/semver/i, err) + end + end + + def test_rejects_a_different_or_nested_release_tag_url + with_tmp_dir do |dist| + %W[hive_#{Hive::VERSION}_darwin_arm64 hive_#{Hive::VERSION}_linux_x86_64 install.sh].each do |name| + File.write(File.join(dist, name), name) + end + + [ + "https://github.com/ivankuznetsov/hive/releases/download/v9.9.9", + "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}/nested" + ].each do |url| + _out, err, status = Open3.capture3( + "script/release-manifest", "--dist", dist, "--version", "v#{Hive::VERSION}", "--base-url", url + ) + refute status.success? + assert_match(/canonical/i, err) + end + end + end +end diff --git a/test/smoke/packaged_binary.sh b/test/smoke/packaged_binary.sh new file mode 100755 index 00000000..c667d762 --- /dev/null +++ b/test/smoke/packaged_binary.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +binary=${1:?usage: test/smoke/packaged_binary.sh /path/to/hive_binary} +expected_version=${2:?usage: test/smoke/packaged_binary.sh /path/to/hive_binary VERSION} + +env -u RUBYOPT -u RUBYLIB PATH="$(dirname "$(command -v env)"):/usr/bin:/bin" \ + "$binary" --version | grep -Fx "$expected_version" +env -u RUBYOPT -u RUBYLIB PATH="$(dirname "$(command -v env)"):/usr/bin:/bin" \ + "$binary" help >/dev/null diff --git a/test/test_helper.rb b/test/test_helper.rb index d251d52b..009bba93 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -71,12 +71,20 @@ module HiveTestHelper def with_tmp_global_config dir = Dir.mktmpdir("hive-global") old = ENV["HIVE_HOME"] + old_xdg = %w[XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME].to_h { |key| [key, ENV[key]] } ENV["HIVE_HOME"] = dir + ENV["XDG_CONFIG_HOME"] = File.join(dir, "xdg-config") + ENV["XDG_DATA_HOME"] = File.join(dir, "xdg-data") + ENV["XDG_STATE_HOME"] = File.join(dir, "xdg-state") + ENV["XDG_CACHE_HOME"] = File.join(dir, "xdg-cache") File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml) begin yield(dir) ensure ENV["HIVE_HOME"] = old + old_xdg.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end # Same race-tolerant cleanup as `with_tmp_dir`: tests inside this # tmpdir invoke `hive`/git subprocesses that can leave the tree # mid-rename. diff --git a/test/unit/assets_test.rb b/test/unit/assets_test.rb new file mode 100644 index 00000000..2b720d12 --- /dev/null +++ b/test/unit/assets_test.rb @@ -0,0 +1,45 @@ +require "test_helper" +require "hive/assets" +require "hive/paths" + +class AssetsTest < Minitest::Test + include HiveTestHelper + + def test_hydrates_versioned_assets_and_preserves_modified_project_files + with_tmp_dir do |root| + paths = Hive::Paths.new(env: {}, home: File.join(root, "home")) + project = File.join(root, "project") + FileUtils.mkdir_p(project) + + first = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0") + + assert_equal File.join(paths.releases, "0.1.0"), File.realpath(paths.current_assets) + assert_includes first.generated, "README.md" + readme = File.join(project, ".hive", "README.md") + File.write(readme, "my project notes\n") + + second = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0") + + assert_includes second.preserved, "README.md" + assert_equal "my project notes\n", File.read(readme) + assert File.file?(File.join(project, ".hive", "install-manifest.yml")) + end + end + + def test_recovers_ownership_when_scaffolding_was_interrupted_before_manifest_write + with_tmp_dir do |root| + paths = Hive::Paths.new(env: {}, home: File.join(root, "home")) + project = File.join(root, "project") + interrupted = File.join(project, ".hive", "README.md") + FileUtils.mkdir_p(File.dirname(interrupted)) + FileUtils.cp("assets/project/.hive/README.md", interrupted) + + report = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0") + manifest = YAML.safe_load(File.read(File.join(project, ".hive", "install-manifest.yml"))) + + assert_includes report.generated, "README.md" + refute_includes report.preserved, "README.md" + assert_equal Digest::SHA256.file(interrupted).hexdigest, manifest.fetch("files").fetch("README.md") + end + end +end diff --git a/test/unit/command_name_test.rb b/test/unit/command_name_test.rb new file mode 100644 index 00000000..2c5561bb --- /dev/null +++ b/test/unit/command_name_test.rb @@ -0,0 +1,14 @@ +require "test_helper" +require "hive/command_name" + +class CommandNameTest < Minitest::Test + def test_hive_and_hv_are_equivalent_public_command_names + assert_equal "hive", Hive::CommandName.from("/usr/local/bin/hive").value + assert_equal "hv", Hive::CommandName.from("/home/me/.local/bin/hv").value + assert_equal "hv init", Hive::CommandName.from("hv").usage("init") + end + + def test_rejects_unknown_argv_zero_aliases + assert_raises(Hive::CommandName::Invalid) { Hive::CommandName.from("other") } + end +end diff --git a/test/unit/commands/daemon_service_test.rb b/test/unit/commands/daemon_service_test.rb new file mode 100644 index 00000000..d3850f06 --- /dev/null +++ b/test/unit/commands/daemon_service_test.rb @@ -0,0 +1,67 @@ +require "test_helper" +require "hive/commands/daemon" +require "hive/paths" + +class DaemonServiceTest < Minitest::Test + include HiveTestHelper + + def test_installed_daemon_start_delegates_to_the_service_manager + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + manager = Object.new + manager.define_singleton_method(:start!) { :running } + + out, _err = capture_io do + result = Hive::Commands::Daemon.new("start", paths: paths, service_manager: manager).call + assert_equal :running, result + end + + assert_includes out, "daemon service running" + end + end + + def test_service_status_keeps_the_not_running_exit_contract + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + manager = Object.new + manager.define_singleton_method(:status) { :registered_stopped } + + error = assert_raises(Hive::Error) do + Hive::Commands::Daemon.new("status", paths: paths, service_manager: manager).call + end + assert_match(/registered stopped/, error.message) + end + end + + def test_service_managed_commands_reject_json_before_mutation + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + called = false + manager = Object.new + manager.define_singleton_method(:start!) { called = true } + + error = assert_raises(Hive::InvalidTaskPath) do + Hive::Commands::Daemon.new("start", paths: paths, service_manager: manager, json: true).call + end + + assert_match(/--json.*not supported/i, error.message) + refute called + end + end + + def test_source_daemon_runtime_uses_xdg_state_and_cache_paths + with_tmp_dir do |home| + paths = Hive::Paths.new( + env: { + "XDG_STATE_HOME" => File.join(home, "state"), + "XDG_CACHE_HOME" => File.join(home, "cache") + }, + home: home + ) + daemon = Hive::Commands::Daemon.new("status", paths: paths, hive_home: File.join(home, "legacy")) + + assert_equal File.join(paths.state, "daemon.pid"), daemon.pid_file + assert_equal File.join(paths.cache, "daemon.log"), daemon.log_file + end + end +end diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb new file mode 100644 index 00000000..61e22207 --- /dev/null +++ b/test/unit/commands/uninstall_test.rb @@ -0,0 +1,188 @@ +require "test_helper" +require "hive/commands/uninstall" +require "hive/paths" +require "hive/install/receipt" +require "digest" + +class UninstallTest < Minitest::Test + include HiveTestHelper + + def test_purge_removes_only_unchanged_manifest_owned_project_files + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + project = File.join(home, "project") + hive = File.join(project, ".hive") + FileUtils.mkdir_p(hive) + unchanged = File.join(hive, "generated.yml") + edited = File.join(hive, "edited.yml") + unknown = File.join(hive, "output.txt") + File.write(unchanged, "generated\n") + File.write(edited, "edited by user\n") + File.write(unknown, "completed output\n") + File.write(File.join(hive, "install-manifest.yml"), { + "schema_version" => 1, + "files" => { + "generated.yml" => Digest::SHA256.hexdigest("generated\n"), + "edited.yml" => Digest::SHA256.hexdigest("original\n") + } + }.to_yaml) + Hive::Install::Receipt.new( + channel: "bash", package_id: "hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: File.join(home, ".local", "bin", "hive"), command_name: "hive" + ).write!(paths) + calls = [] + service = Object.new + service.define_singleton_method(:unregister!) { :unregistered } + + result = Hive::Commands::Uninstall.new( + paths: paths, project_root: project, purge: true, service_manager: service, + runner: ->(*command) { calls << command; [ "", "", true ] } + ).call + + refute File.exist?(unchanged) + assert File.exist?(edited) + assert File.exist?(unknown) + assert_equal [ "edited.yml", "output.txt" ], result.fetch("retained").sort + assert_equal [ "bash", "-o", "pipefail", "-c" ], calls.first.take(4) + assert_match(/install\.sh/, calls.first.last) + end + end + + def test_rejects_manifest_paths_and_symlinked_parents_that_escape_dot_hive + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + project = File.join(home, "project") + hive = File.join(project, ".hive") + outside = File.join(project, "outside.txt") + FileUtils.mkdir_p(hive) + File.write(outside, "generated\n") + File.symlink(project, File.join(hive, "linked")) + File.write(File.join(hive, "install-manifest.yml"), { + "schema_version" => 1, + "files" => { + "../outside.txt" => Digest::SHA256.file(outside).hexdigest, + "linked/outside.txt" => Digest::SHA256.file(outside).hexdigest + } + }.to_yaml) + write_bash_receipt(paths, home) + service_called = false + service = Object.new + service.define_singleton_method(:unregister!) { service_called = true } + runner_called = false + + error = assert_raises(Hive::ConfigError) do + Hive::Commands::Uninstall.new( + paths: paths, project_root: project, purge: true, service_manager: service, + runner: ->(*_command) { runner_called = true; [ "", "", true ] } + ).call + end + + assert_match(/unsafe.*manifest path/i, error.message) + assert_equal "generated\n", File.read(outside) + refute service_called + refute runner_called + end + end + + def test_rejects_a_hash_matching_file_beneath_a_symlinked_manifest_parent + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + project = File.join(home, "project") + hive = File.join(project, ".hive") + outside_dir = File.join(home, "outside") + outside = File.join(outside_dir, "generated.yml") + FileUtils.mkdir_p(hive) + FileUtils.mkdir_p(outside_dir) + File.write(outside, "generated\n") + File.symlink(outside_dir, File.join(hive, "linked")) + File.write(File.join(hive, "install-manifest.yml"), { + "schema_version" => 1, + "files" => { "linked/generated.yml" => Digest::SHA256.file(outside).hexdigest } + }.to_yaml) + write_bash_receipt(paths, home) + service = Object.new + service.define_singleton_method(:unregister!) { flunk "service mutation must not occur" } + + error = assert_raises(Hive::ConfigError) do + Hive::Commands::Uninstall.new( + paths: paths, project_root: project, purge: true, service_manager: service, + runner: ->(*_command) { flunk "package mutation must not occur" } + ).call + end + + assert_match(/symlinked parent/i, error.message) + assert_equal "generated\n", File.read(outside) + end + end + + def test_package_manager_failure_leaves_service_project_and_receipt_recoverable + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + project = File.join(home, "project") + hive = File.join(project, ".hive") + FileUtils.mkdir_p(hive) + generated = File.join(hive, "generated.yml") + File.write(generated, "generated\n") + File.write(File.join(hive, "install-manifest.yml"), { + "schema_version" => 1, + "files" => { "generated.yml" => Digest::SHA256.file(generated).hexdigest } + }.to_yaml) + write_bash_receipt(paths, home) + service_called = false + service = Object.new + service.define_singleton_method(:unregister!) { service_called = true } + + error = assert_raises(Hive::Error) do + Hive::Commands::Uninstall.new( + paths: paths, project_root: project, purge: true, service_manager: service, + runner: ->(*_command) { [ "", "network unavailable", false ] } + ).call + end + + assert_match(/remains registered|no service or project files were removed/i, error.message) + refute service_called + assert File.file?(generated) + assert File.file?(paths.receipt) + end + end + + def test_success_removes_generated_shared_assets_but_preserves_config_and_state + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + project = File.join(home, "project") + FileUtils.mkdir_p(project) + FileUtils.mkdir_p(paths.releases) + FileUtils.mkdir_p(File.dirname(paths.current_assets)) + release = File.join(paths.releases, "0.1.0") + FileUtils.mkdir_p(release) + File.write(File.join(release, "asset"), "generated") + File.symlink(release, paths.current_assets) + FileUtils.mkdir_p(File.dirname(paths.config_file)) + File.write(paths.config_file, "provider: user-owned\n") + write_bash_receipt(paths, home) + service = Object.new + service.define_singleton_method(:unregister!) { :unregistered } + + result = Hive::Commands::Uninstall.new( + paths: paths, project_root: project, purge: true, service_manager: service, + runner: ->(*_command) { [ "", "", true ] } + ).call + + refute File.exist?(paths.current_assets) + refute File.exist?(paths.releases) + assert File.file?(paths.config_file) + assert_includes result.fetch("shared_removed"), paths.releases + end + end + + private + + def write_bash_receipt(paths, home) + Hive::Install::Receipt.new( + channel: "bash", package_id: "hive", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: File.join(home, ".local", "bin", "hive"), + command_name: "hive" + ).write!(paths) + end +end diff --git a/test/unit/commands/update_test.rb b/test/unit/commands/update_test.rb new file mode 100644 index 00000000..6c9b2d6c --- /dev/null +++ b/test/unit/commands/update_test.rb @@ -0,0 +1,58 @@ +require "test_helper" +require "hive/commands/update" +require "hive/paths" +require "hive/install/receipt" + +class UpdateTest < Minitest::Test + include HiveTestHelper + + def test_delegates_a_homebrew_update_without_writing_the_executable + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + Hive::Install::Receipt.new( + channel: "homebrew", package_id: "ivankuznetsov/hive/hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive" + ).write!(paths) + calls = [] + dependencies = Object.new + dependencies.define_singleton_method(:diagnostics_for) { |_action| [] } + updater = Hive::Commands::Update.new( + paths: paths, dependencies: dependencies, + runner: ->(*command) { calls << command; [ "", "", true ] } + ) + + updater.call + + assert_equal [ [ "brew", "upgrade", "ivankuznetsov/hive/hive" ] ], calls + refute File.exist?("/opt/homebrew/bin/hive"), "the updater must not replace a package-manager binary directly" + end + end + + + def test_reports_channel_specific_missing_dependencies_before_mutation + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + Hive::Install::Receipt.new( + channel: "homebrew", package_id: "ivankuznetsov/hive/hive", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive" + ).write!(paths) + diagnostic = Hive::Dependencies::Diagnostic.new("brew", "install Homebrew first", true) + dependencies = Object.new + dependencies.define_singleton_method(:diagnostics_for) { |action| action == :update_homebrew ? [ diagnostic ] : [] } + output = StringIO.new + runner_called = false + + error = assert_raises(Hive::Error) do + Hive::Commands::Update.new( + paths: paths, dependencies: dependencies, output: output, + runner: ->(*_command) { runner_called = true; [ "", "", true ] } + ).call + end + + assert_match(/brew/, error.message) + assert_includes output.string, "install Homebrew first" + refute runner_called + end + end +end diff --git a/test/unit/dependencies_test.rb b/test/unit/dependencies_test.rb new file mode 100644 index 00000000..92ef3e44 --- /dev/null +++ b/test/unit/dependencies_test.rb @@ -0,0 +1,22 @@ +require "test_helper" +require "hive/dependencies" + +class DependenciesTest < Minitest::Test + def test_reports_action_specific_missing_tools_without_executing_package_managers + calls = [] + dependencies = Hive::Dependencies.new(which: ->(tool) { calls << tool; nil }, platform: :linux) + + diagnostics = dependencies.diagnostics_for(:init) + + assert_equal %w[git bash claude gh jq], diagnostics.map(&:tool) + assert_match(/apt|pacman/i, diagnostics.first.hint) + assert_equal %w[git bash claude gh jq], calls + end + + def test_optional_agent_tools_do_not_block_core_status + dependencies = Hive::Dependencies.new(which: ->(_tool) { nil }, platform: :linux) + + assert_empty dependencies.missing_for(:daemon_status) + assert_equal %w[claude gh jq], dependencies.missing_for(:agent_assist).map(&:tool) + end +end diff --git a/test/unit/install/channel_test.rb b/test/unit/install/channel_test.rb new file mode 100644 index 00000000..21288784 --- /dev/null +++ b/test/unit/install/channel_test.rb @@ -0,0 +1,41 @@ +require "test_helper" +require "hive/install/channel" +require "hive/install/receipt" + +class InstallChannelTest < Minitest::Test + def receipt(channel) + package_id = { "bash" => "hive", "homebrew" => "ivankuznetsov/hive/hive", "aur" => "hive-bin" }.fetch(channel) + executable_path = channel == "homebrew" ? "/opt/homebrew/bin/hive" : "/usr/bin/hive" + Hive::Install::Receipt.new( + channel: channel, package_id: package_id, release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: executable_path, command_name: "hive" + ) + end + + def test_selects_only_the_recorded_channel_adapter + assert_instance_of Hive::Install::Homebrew, Hive::Install::Channel.for(receipt("homebrew")) + assert_instance_of Hive::Install::Aur, Hive::Install::Channel.for(receipt("aur")) + assert_instance_of Hive::Install::Bash, Hive::Install::Channel.for(receipt("bash")) + end + + def test_bash_update_and_remove_use_pipefail_and_the_recorded_executable_directory + calls = [] + adapter = Hive::Install::Bash.new(receipt("bash"), runner: ->(*command) { calls << command; [ "", "", true ] }) + + adapter.update! + adapter.remove! + + assert_equal [ "bash", "-o", "pipefail", "-c" ], calls[0].take(4) + assert_includes calls[0].last, "HIVE_BIN_DIR=/usr/bin" + assert_includes calls[1].last, "--uninstall" + assert_equal [ "bash", "-o", "pipefail", "-c" ], calls[1].take(4) + end + + def test_bash_download_failure_is_not_hidden_by_a_successful_empty_shell + adapter = Hive::Install::Bash.new(receipt("bash"), runner: ->(*_command) { [ "", "download failed", false ] }) + + error = assert_raises(Hive::Error) { adapter.update! } + + assert_match(/bash failed/, error.message) + end +end diff --git a/test/unit/install/receipt_test.rb b/test/unit/install/receipt_test.rb new file mode 100644 index 00000000..75f4b429 --- /dev/null +++ b/test/unit/install/receipt_test.rb @@ -0,0 +1,119 @@ +require "test_helper" +require "hive/paths" +require "hive/install/receipt" + +class ReceiptTest < Minitest::Test + include HiveTestHelper + + def test_round_trips_an_atomic_versioned_install_receipt + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + receipt = Hive::Install::Receipt.new( + channel: "bash", package_id: "hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: File.join(home, ".local/bin/hive"), command_name: "hive" + ) + + receipt.write!(paths) + loaded = Hive::Install::Receipt.load(paths) + + assert_equal receipt.to_h, loaded.to_h + assert_equal 0o600, File.stat(paths.receipt).mode & 0o777 + end + end + + def test_refuses_malformed_receipts_instead_of_guessing_a_package_manager + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + FileUtils.mkdir_p(File.dirname(paths.receipt)) + File.write(paths.receipt, "channel: who-knows\n") + + error = assert_raises(Hive::Install::Receipt::Invalid) { Hive::Install::Receipt.load(paths) } + assert_match(/repair|remove/i, error.message) + end + end + + def test_rejects_channel_specific_identity_mismatches + invalid = [ + { + channel: "bash", package_id: "other", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/home/me/.local/bin/hive", command_name: "hive" + }, + { + channel: "homebrew", package_id: "attacker/tap/hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive" + }, + { + channel: "aur", package_id: "hive-bin", release_base_url: "https://example.invalid/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/usr/bin/hive", command_name: "hive" + }, + { + channel: "aur", package_id: "hive-bin", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/tmp/../usr/bin/hive", command_name: "hive" + }, + { + channel: "homebrew", package_id: "ivankuznetsov/hive/hive", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/tmp/hive", command_name: "hive" + }, + { + channel: "aur", package_id: "hive-bin", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0", + installed_version: "0.1.0", executable_path: "/opt/hive", command_name: "hive" + } + ] + + invalid.each do |values| + error = assert_raises(Hive::Install::Receipt::Invalid) { Hive::Install::Receipt.new(**values) } + assert_match(/repair|remove/i, error.message) + end + end + + def test_creates_and_refreshes_a_native_package_receipt_from_package_owned_metadata + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + prefix = File.join(home, "homebrew-prefix") + executable = File.join(prefix, "bin", "hive") + marker = File.join(prefix, "share", "hive", "install-channel.yml") + FileUtils.mkdir_p(File.dirname(executable)) + FileUtils.mkdir_p(File.dirname(marker)) + File.write(executable, "binary") + File.chmod(0o755, executable) + File.write(marker, { "channel" => "homebrew", "package_id" => "ivankuznetsov/hive/hive" }.to_yaml) + + receipt = Hive::Install::Receipt.ensure_native!(paths: paths, program_name: executable) + + assert_equal "homebrew", receipt.channel + assert_equal executable, receipt.executable_path + assert_equal "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", receipt.release_base_url + assert_equal receipt.to_h, Hive::Install::Receipt.load(paths).to_h + end + end + + def test_native_package_marker_refuses_to_replace_a_conflicting_receipt + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + prefix = File.join(home, "homebrew-prefix") + executable = File.join(prefix, "bin", "hive") + marker = File.join(prefix, "share", "hive", "install-channel.yml") + FileUtils.mkdir_p(File.dirname(executable)) + FileUtils.mkdir_p(File.dirname(marker)) + File.write(executable, "binary") + File.chmod(0o755, executable) + File.write(marker, { "channel" => "homebrew", "package_id" => "ivankuznetsov/hive/hive" }.to_yaml) + existing = Hive::Install::Receipt.new( + channel: "bash", package_id: "hive", + release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", + installed_version: Hive::VERSION, executable_path: File.join(home, ".local", "bin", "hive"), + command_name: "hive" + ) + existing.write!(paths) + + error = assert_raises(Hive::Install::Receipt::Invalid) do + Hive::Install::Receipt.ensure_native!(paths: paths, program_name: executable) + end + + assert_match(/conflicts/i, error.message) + assert_equal existing.to_h, Hive::Install::Receipt.load(paths).to_h + end + end +end diff --git a/test/unit/package_contract_test.rb b/test/unit/package_contract_test.rb new file mode 100644 index 00000000..9cb46e7b --- /dev/null +++ b/test/unit/package_contract_test.rb @@ -0,0 +1,22 @@ +require "test_helper" +require "json" +require "open3" + +class PackageContractTest < Minitest::Test + def test_package_script_exposes_the_tier_one_artifact_contract + out, err, status = Open3.capture3("script/package", "--print-contract") + + assert status.success?, err + contract = JSON.parse(out) + assert_equal Hive::VERSION, contract.fetch("version") + assert_equal [ + { "os" => "darwin", "arch" => "arm64", "name" => "hive_#{Hive::VERSION}_darwin_arm64" }, + { "os" => "linux", "arch" => "x86_64", "name" => "hive_#{Hive::VERSION}_linux_x86_64" } + ], contract.fetch("targets") + assert_equal "3.4.2", contract.fetch("ruby") + assert_equal "tebako", contract.fetch("packager") + assert_equal %w[Gemfile Gemfile.lock exe bin lib assets templates schemas], contract.fetch("payload_entries") + refute_includes contract.fetch("payload_entries"), ".git" + refute_includes contract.fetch("payload_entries"), "test" + end +end diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb new file mode 100644 index 00000000..b0b395d9 --- /dev/null +++ b/test/unit/paths_test.rb @@ -0,0 +1,43 @@ +require "test_helper" +require "hive/paths" + +class PathsTest < Minitest::Test + include HiveTestHelper + + def test_uses_required_xdg_defaults_without_mutating_legacy_state + with_tmp_dir do |home| + legacy = File.join(home, ".hive-state") + FileUtils.mkdir_p(legacy) + legacy_file = File.join(legacy, "completed.yml") + File.write(legacy_file, "finished: true\n") + before = File.stat(legacy_file) + + paths = Hive::Paths.new(env: {}, home: home) + + assert_equal File.join(home, ".config", "hive"), paths.config + assert_equal File.join(home, ".local", "share", "hive"), paths.data + assert_equal File.join(home, ".local", "state", "hive"), paths.state + assert_equal File.join(home, ".cache", "hive"), paths.cache + assert_equal legacy, paths.legacy_state + assert_equal before.mtime, File.stat(legacy_file).mtime + assert_equal "finished: true\n", File.read(legacy_file) + end + end + + def test_honors_all_xdg_overrides_and_keeps_hive_content_beneath_them + with_tmp_dir do |root| + env = { + "XDG_CONFIG_HOME" => File.join(root, "config"), + "XDG_DATA_HOME" => File.join(root, "data"), + "XDG_STATE_HOME" => File.join(root, "state"), + "XDG_CACHE_HOME" => File.join(root, "cache") + } + paths = Hive::Paths.new(env: env, home: File.join(root, "home")) + + assert_equal File.join(root, "config", "hive"), paths.config + assert_equal File.join(root, "data", "hive"), paths.data + assert_equal File.join(root, "state", "hive", "install.yml"), paths.receipt + assert_equal File.join(root, "cache", "hive"), paths.cache + end + end +end diff --git a/test/unit/platform_test.rb b/test/unit/platform_test.rb new file mode 100644 index 00000000..78481ae5 --- /dev/null +++ b/test/unit/platform_test.rb @@ -0,0 +1,12 @@ +require "test_helper" +require "hive/platform" + +class PlatformTest < Minitest::Test + def test_normalizes_tier_one_hosts + assert_equal [:macos, :arm64], Hive::Platform.current(host_os: "darwin24.0", host_cpu: "aarch64") + assert_equal [:linux, :x86_64], Hive::Platform.current(host_os: "linux-gnu", host_cpu: "x86_64") + assert Hive::Platform.supported?(:macos, :arm64) + assert Hive::Platform.supported?(:linux, :x86_64) + refute Hive::Platform.supported?(:linux, :arm64) + end +end diff --git a/test/unit/service/launchd_test.rb b/test/unit/service/launchd_test.rb new file mode 100644 index 00000000..005ad90b --- /dev/null +++ b/test/unit/service/launchd_test.rb @@ -0,0 +1,26 @@ +require "test_helper" +require "hive/service/launchd" +require "hive/paths" + +class LaunchdTest < Minitest::Test + include HiveTestHelper + + def test_writes_a_per_user_plist_without_loading_it + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + path = Hive::Service::Launchd.write!(paths: paths, executable_path: "/opt/homebrew/bin/hive") + + assert_equal File.join(home, "Library", "LaunchAgents", "dev.hive.daemon.plist"), path + content = File.read(path) + assert_includes content, "/opt/homebrew/bin/hive" + assert_includes content, "HIVE_SERVICE_CHILD" + assert_includes content, "XDG_CONFIG_HOME" + assert_includes content, CGI.escapeHTML(File.dirname(paths.config)) + assert_includes content, "XDG_DATA_HOME" + assert_includes content, "XDG_CACHE_HOME" + assert_includes content, "RunAtLoad" + assert_includes content, "KeepAlive" + refute_includes content, "RunAtLoad" + end + end +end diff --git a/test/unit/service/manager_test.rb b/test/unit/service/manager_test.rb new file mode 100644 index 00000000..372e0039 --- /dev/null +++ b/test/unit/service/manager_test.rb @@ -0,0 +1,68 @@ +require "test_helper" +require "hive/service/manager" +require "hive/paths" + +class ServiceManagerTest < Minitest::Test + include HiveTestHelper + + def test_linux_register_then_start_and_stop_uses_systemd_user_without_duplicates + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + commands = [] + manager = Hive::Service::Manager.new( + paths: paths, platform: :linux, executable_path: "/usr/bin/hive", + runner: ->(*command) { commands << command; [ "", true ] } + ) + + assert_equal :registered_stopped, manager.register! + assert_equal :running, manager.start! + assert_equal :stopped, manager.stop! + + assert_includes commands, [ "systemctl", "--user", "daemon-reload" ] + assert_includes commands, [ "systemctl", "--user", "enable", "--now", "hive.service" ] + assert_includes commands, [ "systemctl", "--user", "disable", "--now", "hive.service" ] + assert_equal 1, Dir.glob(File.join(File.dirname(paths.config), "systemd", "user", "hive.service")).size + end + end + + def test_status_reports_unregistered_without_calling_a_service_manager + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + manager = Hive::Service::Manager.new(paths: paths, platform: :linux, executable_path: "/usr/bin/hive") + + assert_equal :unregistered, manager.status + end + end + + def test_propagates_service_manager_failures_without_claiming_running + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + manager = Hive::Service::Manager.new( + paths: paths, platform: :linux, executable_path: "/usr/bin/hive", + runner: ->(*_command) { [ "", "user manager unavailable", false ] } + ) + + error = assert_raises(Hive::Error) { manager.start! } + + assert_match(/systemctl.*failed/i, error.message) + assert_match(/user manager unavailable/, error.message) + end + end + + def test_macos_explicit_start_bootstraps_then_kickstarts_a_non_autostart_agent + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + commands = [] + manager = Hive::Service::Manager.new( + paths: paths, platform: :macos, executable_path: "/opt/homebrew/bin/hive", uid: 501, + runner: ->(*command) { commands << command; [ "", "", true ] } + ) + + assert_equal :running, manager.start! + assert_equal [ + [ "launchctl", "bootstrap", "gui/501", Hive::Service::Launchd.path(paths) ], + [ "launchctl", "kickstart", "-k", "gui/501/dev.hive.daemon" ] + ], commands + end + end +end diff --git a/test/unit/service/systemd_user_test.rb b/test/unit/service/systemd_user_test.rb new file mode 100644 index 00000000..5d6b358e --- /dev/null +++ b/test/unit/service/systemd_user_test.rb @@ -0,0 +1,47 @@ +require "test_helper" +require "hive/service/systemd_user" +require "hive/paths" + +class SystemdUserTest < Minitest::Test + include HiveTestHelper + + def test_writes_a_user_unit_with_xdg_paths_and_service_child_marker + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + path = Hive::Service::SystemdUser.write!(paths: paths, executable_path: "/usr/bin/hv") + + content = File.read(path) + assert_equal File.join(File.dirname(paths.config), "systemd", "user", "hive.service"), path + assert_includes content, 'ExecStart="/usr/bin/hv" daemon start' + assert_includes content, 'Environment="HIVE_SERVICE_CHILD=1"' + assert_includes content, paths.state + end + end + + def test_quotes_spaces_quotes_dollars_and_systemd_specifiers + with_tmp_dir do |home| + env = { + "XDG_CONFIG_HOME" => File.join(home, "config with space"), + "XDG_DATA_HOME" => File.join(home, "data%value"), + "XDG_STATE_HOME" => File.join(home, "state$dollar"), + "XDG_CACHE_HOME" => File.join(home, "cache\"quote") + } + paths = Hive::Paths.new(env: env, home: home) + Hive::Service::SystemdUser.write!(paths: paths, executable_path: File.join(home, "bin with space", "hi%ve$")) + + content = File.read(Hive::Service::SystemdUser.path(paths)) + assert_includes content, 'Environment="XDG_CONFIG_HOME=' + assert_includes content, "data%%value" + assert_includes content, "state$dollar" + assert_includes content, 'cache\\"quote' + assert_includes content, 'ExecStart="' + assert_includes content, "hi%%ve$$" + end + end + + def test_escapes_backslashes_without_interpreting_replacement_sequences + escaped = Hive::Service::SystemdUser.escape('C:\\Hive\\"100%$path', dollar: true) + + assert_equal 'C:\\\\Hive\\\\\\"100%%$$path', escaped + end +end diff --git a/test/unit/user_config_test.rb b/test/unit/user_config_test.rb new file mode 100644 index 00000000..d800d5d2 --- /dev/null +++ b/test/unit/user_config_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "hive/user_config" +require "hive/paths" + +class UserConfigTest < Minitest::Test + include HiveTestHelper + + def test_writes_only_a_credential_reference_with_restrictive_permissions + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + config = Hive::UserConfig.ensure!(paths: paths, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY") + + assert_equal "anthropic", config.fetch("provider") + assert_equal "ANTHROPIC_API_KEY", config.fetch("credential_env") + text = File.read(paths.config_file) + refute_includes text, "actual-secret" + assert_equal 0o600, File.stat(paths.config_file).mode & 0o777 + end + end + + def test_keeps_existing_user_configuration_unchanged + with_tmp_dir do |home| + paths = Hive::Paths.new(env: {}, home: home) + FileUtils.mkdir_p(File.dirname(paths.config_file)) + File.write(paths.config_file, { "provider" => "custom", "model" => "custom-model", "credential_env" => "CUSTOM_TOKEN" }.to_yaml) + + config = Hive::UserConfig.ensure!(paths: paths, provider: "anthropic", model: "ignored", credential_env: "ANTHROPIC_API_KEY") + + assert_equal "custom", config.fetch("provider") + assert_equal "CUSTOM_TOKEN", config.fetch("credential_env") + end + end +end diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md index 308f04ce..d6d0e829 100644 --- a/wiki/commands/daemon.md +++ b/wiki/commands/daemon.md @@ -30,11 +30,11 @@ hive daemon disable PROJECT | --all [--json] | Subcommand | Behavior | |-----------|----------| -| `start` | Acquires the PID file (`~/Dev/hive/.daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. | +| `start` | Acquires the PID file (`$XDG_STATE_HOME/hive/daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. Installed service-managed start/stop/status reject `--json` explicitly rather than returning an empty response. | | `stop` | Sends `SIGTERM` to the running daemon's PID. Waits up to `daemon.shutdown_grace_sec` (default 600s) for the daemon to exit, then escalates to `SIGKILL`. Idempotent: `stop` with no PID file exits 0 with `daemon not running` on stderr; a stale PID file (process gone) is removed and the call exits 0. With `--json`, emits a `hive-daemon-stop` envelope (fields: `running`, `was_running`, `stale_pid?`, `reason?` — `pid_reused` / `unverified` for safety bailouts). | | `status` | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`. | | `reload` | Sends `SIGHUP` to the running daemon's PID, which triggers config reload at the next tick boundary. In-flight children continue uninterrupted. Exit 1 if no daemon running. With `--json`, emits a `hive-daemon-reload` envelope (`ok`, `reason`, `pid`, `message`). | -| `tail` | `tail -F` semantics on `~/Dev/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. | +| `tail` | `tail -F` semantics on `$XDG_CACHE_HOME/hive/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. | | `enable` | Sets `daemon.enabled: true` in `/.hive-state/config.yml`. Surgical line-level YAML editor (upsert) preserves comments, key order, and file-mode bits across enable/disable flips; rejects inline-flow `daemon: { ... }`, CRLF endings, and 4-space-indented children before any write. Atomic write goes via tempfile + `flock(LOCK_EX)` + `fsync` + rename; tempfile is ensure-cleaned on rename failure (ENOSPC / EACCES / EXDEV). Pre-flight (`preflight_targets`) validates every target before any write so `--all` cannot half-flip the registry on a bad middle project. Pass a registered project name OR `--all` (mutually exclusive — passing both raises USAGE 64). Exit 64 on missing/unknown target / not-initialised project / no registered projects. With `--json`, emits a `hive-daemon-enroll` envelope on success and an `EnrollErrorKind` JSON error envelope on failure (`missing_project` / `unknown_project` / `project_and_all` / `not_initialised` / `no_projects` / `config` / `internal`); YAML parse failures surface as `Hive::ConfigError` (exit 78). | | `disable` | Same shape as `enable`, sets `daemon.enabled: false`. The next dispatcher tick honours the change automatically (per-tick enable-cache invalidation); `hive daemon reload` is optional for instant pickup. | @@ -93,7 +93,6 @@ All under `daemon:` in `~/Dev/hive/config.yml`: | `max_runs_per_day_per_project` | 50 | Circuit breaker for runaway loops. | | `transient_retry_backoff_sec` | 60 | Base of `60 → 120 → 300 s` backoff schedule. | | `shutdown_grace_sec` | 600 | TERM→KILL window for in-flight children on `daemon stop`. | -| `log_file` | `~/Dev/hive/logs/daemon.log` | Structured-log destination. | | `log_max_bytes` | 10485760 | 10 MB rotation threshold. | | `log_max_files` | 5 | 5 × 10 MB = 50 MB log budget. | @@ -111,7 +110,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`: ## Structured log -`~/Dev/hive/logs/daemon.log` is one JSON document per line: +`$XDG_CACHE_HOME/hive/daemon.log` is one JSON document per line: ```json {"ts":"2026-05-06T12:00:00Z","schema":"hive-daemon-log","schema_version":1,"event":"dispatched","pid":12345,"project":"writero","slug":"fix-x","stage":"6-review","command":"hive run fix-x --json","dry_run":false} diff --git a/wiki/dependencies.md b/wiki/dependencies.md index ddbf67af..6cccc99c 100644 --- a/wiki/dependencies.md +++ b/wiki/dependencies.md @@ -53,6 +53,16 @@ The codebase leans heavily on stdlib (no extra gems for these): The `/proc//stat` reliance is Linux-specific. macOS would need a `ps -o lstart= -p ` fallback (noted as a known limitation in the plan but not implemented in MVP). +## Distribution diagnostics + +`Hive::Dependencies` is the production read-only prerequisite checker used by +`hive init` and channel-aware `hive update`. Init reports missing `git`, `bash`, +`claude`, `gh`, and `jq` with platform-specific recovery hints without invoking +a package manager. Update checks only its recorded channel (`bash`/`curl`, +`brew`, or `HIVE_AUR_HELPER`) and refuses before lifecycle mutation when that +tool is unavailable. The checksum-verifying shell installer performs the same +five-tool diagnostic after artifact verification. + ## External CLI dependencies These are not gems but the CLI tools the runtime invokes: diff --git a/wiki/distribution.md b/wiki/distribution.md new file mode 100644 index 00000000..a47d4638 --- /dev/null +++ b/wiki/distribution.md @@ -0,0 +1,92 @@ +--- +title: Distribution and installation +type: architecture +source: lib/hive/{paths,assets,user_config,install,service,release_manifest}.rb, install.sh, packaging/ +created: 2026-07-15 +updated: 2026-07-15 +tags: [distribution, install, release, xdg] +--- + +**TLDR**: Hive v0.1.0 distribution is built around two Tebako artifacts named +`hive__darwin_arm64` and `hive__linux_x86_64`. A GitHub +Release manifest plus `SHA256SUMS` is the canonical source consumed by the bash +installer and package metadata. User-owned runtime data follows XDG; project +scaffolding lives in `.hive/` and is updated only when its install-manifest hash +proves the file is still Hive-generated. + +## Runtime ownership + +| Content | Location | Ownership | +| --- | --- | --- | +| User config | `$XDG_CONFIG_HOME/hive/config.yml` | User; contains only provider/model/credential-env reference | +| Embedded asset release | `$XDG_DATA_HOME/hive/releases/` | Hive; immutable materialization | +| Asset pointer | `$XDG_DATA_HOME/hive/current` | Hive symlink | +| Install receipt | `$XDG_STATE_HOME/hive/install.yml` | Hive lifecycle metadata | +| Legacy state | `~/.hive-state` | Compatibility input only; never migrated/deleted | +| Project scaffold | `/.hive/` | Mixed; ownership hashes in `install-manifest.yml` | + +`Hive::Paths` is the only resolver for distribution-owned XDG paths, including +the daemon PID (`$XDG_STATE_HOME/hive/daemon.pid`) and log +(`$XDG_CACHE_HOME/hive/daemon.log`). `Hive::Install::Receipt` records the +channel, package identity, release URL, installed version, executable path, and +selected public command (`hive` or collision-safe `hv`). Receipts validate +exact channel package IDs, canonical tag URLs, and channel-specific executable +locations before lifecycle mutation. Homebrew and AUR packages install an +immutable `install-channel.yml`; the first binary invocation turns that marker +into the user-owned XDG receipt and rejects a conflicting existing channel. +[[commands/init]] retains its existing `.hive-state` pipeline worktree; the +distribution scaffold is additive rather than a state migration. + +## Lifecycle safety + +`hive update` selects its adapter from the receipt: Homebrew delegates to +`brew`, AUR to the configured helper, and bash reruns the official installer +with `pipefail` and the receipt's executable directory. No adapter directly +overwrites a package-manager executable. `hive uninstall` validates its project +cleanup plan, delegates package removal before any local cleanup, unregisters +the service only after that succeeds, removes XDG release assets, and removes +only unchanged manifest-owned `.hive` files when confirmed or passed `--purge`. +Config, state, legacy state, unknown files, edited generated files, and the +receipt remain recoverable when package removal fails. Manifest paths must +remain lexically and physically beneath `.hive/`; symlinked parents are never +followed. + +On installed releases, `hive init` writes a launchd plist (macOS) or +`systemd --user` unit (Linux). Registration is separate from startup; +interactive init asks with a default-no prompt, non-interactive init defaults +to stopped, and explicit `--start-daemon` grants automation consent. Launchd +uses `RunAtLoad=false` / `KeepAlive=false` and is explicitly kickstarted only +after consent. Both native definitions receive the complete configured XDG +environment; systemd values and executable arguments are quoted. Service +manager failures propagate instead of reporting a false state. The service +child sets `HIVE_SERVICE_CHILD=1` so it runs the existing daemon loop rather +than asking the service manager to start itself again. + +## Release contract + +`script/package` is the Tebako entry point. It first creates an explicit +runtime-only payload from `Gemfile*`, `exe/`, `bin/`, `lib/`, `assets/`, +`templates/`, and `schemas/`; repository metadata, tests, caches, and unrelated +root files are outside the Tebako root. Linux invocations use `--patchelf`, and +the package workflow runs on Ubuntu 22.04 after installing Tebako build +prerequisites. `script/release-manifest` accepts only a stable +`vMAJOR.MINOR.PATCH` tag matching `Hive::VERSION`, requires both target binaries +and `install.sh`, requires the exact canonical URL for that tag, and writes a +schema-validated JSON manifest plus checksums. + +`packaging/homebrew`, `packaging/aur`, and `packaging/hive-skills` are source +bundles for their separately published repositories. Release automation +replaces bootstrap checksum placeholders, regenerates AUR `.SRCINFO`, aligns +skills versions, and pushes idempotent downstream commits from the canonical +manifest only after unit, security, package, and non-skippable +macOS/Ubuntu/Arch acceptance jobs pass. The core installer never writes an +agent-owned skill directory; skills use host-native marketplace/package +layouts. + +## Backlinks + +- [[architecture]] +- [[commands/init]] +- [[commands/daemon]] +- [[dependencies]] +- [[testing]] diff --git a/wiki/gaps.md b/wiki/gaps.md index 7f96bc66..22d992a2 100644 --- a/wiki/gaps.md +++ b/wiki/gaps.md @@ -40,6 +40,21 @@ tags: [gap, todo] ## Open questions about the codebase +0. **Native distribution acceptance is not yet evidenced in this checkout.** The + Tebako command reaches its configure step but this runner has no `cmake`; the + macOS arm64 and Arch AUR checks also require their native CI hosts. The + package, formula, AUR, and now non-skippable candidate-lifecycle acceptance + workflows carry those checks, but a real tagged release still needs their + artifacts recorded before v0.1.0 is declared available. + +0. **Separate channel repositories are not checked out here.** + `packaging/homebrew`, `packaging/aur`, and `packaging/hive-skills` are + deterministic publish sources, not proof that the external tap/AUR/skills + repositories have been updated. Release automation now replaces their + placeholder SHA-256 values, regenerates `.SRCINFO`, aligns skills versions, + and pushes each repository from the canonical release manifest; the first + real tag remains the external publication proof. + 1. **Has `hive run` been smoke-tested against a live `claude` v2.1.118?** The plan calls for this before declaring the MVP done. No evidence in tree (no `docs/solutions/` notes, no `docs/smoke-results.md`). 2. **Has `hive init` been run against a real project yet?** Planned pilot, but the working tree shows no first commit on `~/Dev/hive` itself, so the pilot may not have started. 3. **Is `hive/state` reachable after `git gc`?** The plan recommends `git config --add gc.reflogExpire never refs/heads/hive/state`. This is documented in [[decisions]] ADR-003 but not enforced in `Init#call`. diff --git a/wiki/index.md b/wiki/index.md index 2b728a8f..b7857df5 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -10,8 +10,8 @@ tags: [index, wiki] **TLDR**: Catalog of the LLM-maintained wiki for `hive`. -Page count: 60 -Updated: 2026-05-16 +Page count: 61 +Updated: 2026-07-15 Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eight-stage filesystem state machine (`1-inbox` → `2-brainstorm` → `3-plan` → `4-execute` → `5-open-pr` → `6-review` → `7-finalize` → `8-done`) where stage agents run via configurable AgentProfile CLIs (`claude` default, `codex`, `pi`) and `mv` between directories is the approval primitive. @@ -38,6 +38,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig - [[commands/tui]] — `wiki/commands/tui.md` - [[decisions]] — `wiki/decisions.md` - [[dependencies]] — `wiki/dependencies.md` +- [[distribution]] — `wiki/distribution.md` - [[e2e]] — `wiki/e2e.md` - [[gaps]] — `wiki/gaps.md` - [[index]] — `wiki/index.md` diff --git a/wiki/log.md b/wiki/log.md index 641bec70..3d1f98cc 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,39 @@ Append-only log of all wiki operations. +## [2026-07-15T00:00:01Z] distribution — stage 6 lifecycle and release hardening + +**Action:** Hardened the v0.1.0 distribution review surface. Installer +collisions now cover effective PATH and preserve unrelated aliases; executable +and receipt publication is rollback-safe. Receipts enforce channel identity and +native packages bootstrap them from immutable metadata. Uninstall validates +physical `.hive/` containment, delegates package removal before cleanup, and +removes generated XDG releases without touching config/state. Service startup +uses default-no consent, truthful manager results, quoted XDG definitions, and +XDG PID/log paths. Tebako receives a runtime-only payload, and release +publication is blocked on security plus non-skippable tier-1 lifecycle jobs +before deterministic Homebrew/AUR/skills metadata publication. + +**Refreshed pages:** +- [[distribution]] — lifecycle, native receipts, service consent, payload, and release gates. +- [[commands/daemon]] — XDG runtime paths and installed-service JSON behavior. +- [[dependencies]] — production action-specific prerequisite diagnostics. +- [[gaps]] — acceptance and external publication evidence status. + +## [2026-07-15T00:00:00Z] distribution — v0.1.0 installation contract + +**Action:** Added [[distribution]] to document the Tebako binary contract, +XDG resolver, immutable asset materialization, secure credential references, +channel receipt/update/uninstall safety, per-user service manager, release +manifest, package-source layout, and separate skills package boundary. Logged +the remaining evidence gaps: this runner lacks CMake for a Tebako smoke build, +and the macOS/Arch/external channel repositories require native release CI. + +**Refreshed pages:** +- [[distribution]] — new distribution/lifecycle/release contract. +- [[index]] — catalog count and link. +- [[gaps]] — native acceptance and external publishing evidence gaps. + ## [2026-05-20T00:00:00Z] README rewritten with TUI-first framing **Action:** Restructured `README.md` so the user-facing entry point is the `hive tui` dashboard, the second-tier entry point is "Drive Hive From Your Coding Agent" (folding in the existing install-prompt block plus day-to-day operate-via-agent guidance), and direct CLI use is demoted to a Power-User / Scripting CLI summary that links out to `docs/cli.md` instead of duplicating the per-command table. The hero now explains what Hive does and the folder-as-agent + compound-engineering mental model before any install line. The Documentation section replaces bare bullet links with 1–3 sentence prose descriptions per linked doc.