diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 00000000..02924c96 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,44 @@ +name: Build release artifacts + +on: + workflow_call: + inputs: + version: + required: true + type: string + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + build: + strategy: + matrix: + include: + - os: macos-14 + target: darwin-arm64 + - os: ubuntu-22.04 + target: linux-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.1" + bundler-cache: true + - name: Install pinned Tebako + run: gem install tebako --version 0.12.0 + - name: Build and smoke test + run: scripts/build-release --version "${{ inputs.version }}" --target "${{ matrix.target }}" + - uses: anchore/sbom-action@v0 + with: + path: build/hive-${{ matrix.target }} + output-file: build/hive-${{ matrix.target }}.spdx.json + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: | + build/hive-${{ matrix.target }} + build/hive-${{ matrix.target }}.spdx.json diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml new file mode 100644 index 00000000..b9c384d4 --- /dev/null +++ b/.github/workflows/install-smoke.yml @@ -0,0 +1,62 @@ +name: Install smoke + +on: + workflow_dispatch: + inputs: + version: + description: Released semver without v + required: true + type: string + +permissions: + contents: read + +jobs: + shell: + strategy: + matrix: + os: [ubuntu-22.04, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - name: Install without system Ruby dependency + env: + HIVE_RELEASE_REPOSITORY: ${{ github.repository }} + run: | + env -u GEM_HOME -u GEM_PATH scripts/install.sh --version "${{ inputs.version }}" --yes + "${HOME}/.local/bin/hv" --version + test "$("${HOME}/.local/bin/hv" --version)" = "${{ inputs.version }}" + "${HOME}/.local/bin/hv" update + "${HOME}/.local/bin/hv" uninstall + + homebrew: + runs-on: macos-14 + steps: + - name: Verify published tap lifecycle + run: | + brew install "${GITHUB_REPOSITORY}/hive" + test "$(hv --version)" = "${{ inputs.version }}" + hv update + hv uninstall + + arch: + runs-on: ubuntu-22.04 + container: archlinux:latest + steps: + - run: pacman -Syu --noconfirm base-devel git curl + - name: Verify published AUR package lifecycle without Ruby + env: + AUR_REPOSITORY: ${{ vars.AUR_REPOSITORY }} + run: | + test -n "${AUR_REPOSITORY}" + git clone "https://aur.archlinux.org/${AUR_REPOSITORY}.git" aur + useradd -m builder + chown -R builder:builder aur + runuser -u builder -- bash -lc "cd '${PWD}/aur' && makepkg --noconfirm" + pacman -U --noconfirm aur/hive-bin-*.pkg.tar.* + test "$(hv --version)" = "${{ inputs.version }}" + mkdir -p fake-bin + printf '#!/bin/sh\nif [ "$1" = "-S" ]; then exit 0; fi\nexec pacman "$@"\n' > fake-bin/yay + chmod 0755 fake-bin/yay + PATH="${PWD}/fake-bin:${PATH}" hv update + PATH="${PWD}/fake-bin:${PATH}" hv uninstall diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..513679dc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,361 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + qualify: + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.version.outputs.value }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Require immutable matching semver + id: version + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(ruby -Ilib -rhive/version -e 'print Hive::VERSION')" + test "v${version}" = "${GITHUB_REF_NAME}" + ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 + echo "value=${version}" >> "${GITHUB_OUTPUT}" + - name: Require every v0.1 publication channel + env: + HOMEBREW_TAP_REPOSITORY: ${{ vars.HOMEBREW_TAP_REPOSITORY }} + AUR_REPOSITORY: ${{ vars.AUR_REPOSITORY }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + HIVE_SKILLS_TOKEN: ${{ secrets.HIVE_SKILLS_TOKEN }} + run: | + test -n "${HOMEBREW_TAP_REPOSITORY}" + test -n "${AUR_REPOSITORY}" + test -n "${HOMEBREW_TAP_TOKEN}" + test -n "${AUR_SSH_KEY}" + test -n "${HIVE_SKILLS_TOKEN}" + - run: bundle exec rake test + - run: bundle exec rake distribution + - run: bundle exec rubocop --parallel + - name: Install current advertised clients + run: npm install --global @anthropic-ai/claude-code @openai/codex @mariozechner/pi-coding-agent + - name: Validate Claude marketplace and install + run: | + export CLAUDE_CONFIG_DIR="${RUNNER_TEMP}/claude-config" + claude plugin validate --strict hive-skills + claude plugin marketplace add ./hive-skills --scope user + claude plugin install hive-skills@hive-skills --scope user + claude plugin list | grep -F hive-skills@hive-skills + - name: Validate Codex marketplace and install + run: | + export CODEX_HOME="${RUNNER_TEMP}/codex-home" + mkdir -p "${CODEX_HOME}" + codex plugin marketplace add ./hive-skills + codex plugin add hive-skills@hive-skills + - name: Validate Pi package install + run: | + export PI_CODING_AGENT_DIR="${RUNNER_TEMP}/pi-agent" + pi install ./hive-skills --no-approve + pi list | grep -F hive-skills + + build: + needs: qualify + uses: ./.github/workflows/build-release.yml + with: + version: ${{ needs.qualify.outputs.version }} + secrets: inherit + + candidate: + needs: [qualify, build] + runs-on: ubuntu-22.04 + env: + HIVE_RELEASE_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v5 + with: + pattern: binary-* + path: build + merge-multiple: true + - name: Package, render, and verify the candidate + run: | + scripts/package-release --version "${{ needs.qualify.outputs.version }}" + scripts/verify-release --manifest dist/release-manifest.json + scripts/render-packages --manifest dist/release-manifest.json --output-dir generated + - uses: actions/attest-build-provenance@v2 + with: + subject-path: dist/hive-*.tar.gz + - uses: actions/upload-artifact@v4 + with: + name: qualified-release-candidate + path: | + dist/ + generated/ + build/*.spdx.json + + tier1-shell: + needs: [qualify, candidate] + strategy: + matrix: + os: [ubuntu-22.04, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - uses: actions/download-artifact@v5 + with: + name: qualified-release-candidate + path: candidate + - name: AE3 shell install, collision, update, and preservation gate + run: | + version="${{ needs.qualify.outputs.version }}" + mkdir -p "release-root/v${version}" "release-root/latest" "${RUNNER_TEMP}/foreign-bin" + cp candidate/dist/* "release-root/v${version}/" + cp candidate/dist/* "release-root/latest/" + printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/hive" + printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/systemctl" + chmod 0755 "${RUNNER_TEMP}/foreign-bin/hive" + chmod 0755 "${RUNNER_TEMP}/foreign-bin/systemctl" + export HOME="${RUNNER_TEMP}/home" + export XDG_CONFIG_HOME="${RUNNER_TEMP}/config" + export XDG_DATA_HOME="${RUNNER_TEMP}/data" + export XDG_STATE_HOME="${RUNNER_TEMP}/state" + export XDG_CACHE_HOME="${RUNNER_TEMP}/cache" + export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime" + export HIVE_BIN_DIR="${RUNNER_TEMP}/bin" + export HIVE_INSTALL_BASE_URL="file://${PWD}/release-root" + export PATH="${RUNNER_TEMP}/foreign-bin:${HIVE_BIN_DIR}:${PATH}" + "release-root/v${version}/install.sh" --version "${version}" --yes + test "$("${HIVE_BIN_DIR}/hv" --version)" = "${version}" + test "$("${RUNNER_TEMP}/foreign-bin/hive"; printf preserved)" = "preserved" + project="${RUNNER_TEMP}/project" + git init -b main "${project}" + git -C "${project}" config user.name release-test + git -C "${project}" config user.email release-test@example.com + printf 'release gate\n' > "${project}/README.md" + git -C "${project}" add README.md + git -C "${project}" commit -m initial + "${HIVE_BIN_DIR}/hv" init "${project}" + test -e "${project}/.hive/settings.yml" + if [ "$(uname -s)" = Darwin ]; then + service_definition="${HOME}/Library/LaunchAgents/org.hive.daemon.plist" + else + service_definition="${XDG_CONFIG_HOME}/systemd/user/hive.service" + fi + test -e "${service_definition}" + grep -F "${HIVE_BIN_DIR}/hv" "${service_definition}" + mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output" + printf preserved > "${project}/.agents/skills/hive/SKILL.md" + printf preserved > "${project}/completed-output/result" + "${HIVE_BIN_DIR}/hv" update + "${HIVE_BIN_DIR}/hv" uninstall + test ! -e "${HIVE_BIN_DIR}/hv" + test ! -e "${service_definition}" + test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved + test "$(cat "${project}/completed-output/result")" = preserved + - run: bundle exec ruby -Itest -Ilib test/acceptance/ubuntu_shell_test.rb + - run: bundle exec ruby -Itest -Ilib test/acceptance/uninstall_preserves_work_test.rb + + tier1-homebrew: + needs: [qualify, candidate] + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v5 + with: + name: qualified-release-candidate + path: candidate + - name: AE1 Homebrew lifecycle and collision gate + run: | + version="${{ needs.qualify.outputs.version }}" + formula="${PWD}/candidate/generated/homebrew/Formula/hive.rb" + archive="${PWD}/candidate/dist/hive-${version}-darwin-arm64.tar.gz" + export ARCHIVE="${archive}" + ruby -pi -e 'gsub(%r{url ".*"}, "url \\"file://#{ENV.fetch("ARCHIVE")}\\"")' "${formula}" + mkdir -p "${RUNNER_TEMP}/foreign-bin" + printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/hive" + chmod 0755 "${RUNNER_TEMP}/foreign-bin/hive" + export XDG_CONFIG_HOME="${RUNNER_TEMP}/config" + export XDG_DATA_HOME="${RUNNER_TEMP}/data" + export XDG_STATE_HOME="${RUNNER_TEMP}/state" + export XDG_CACHE_HOME="${RUNNER_TEMP}/cache" + export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime" + export PATH="${RUNNER_TEMP}/foreign-bin:${PATH}" + brew tap-new "${{ github.repository }}" + cp "${formula}" "$(brew --repository "${{ github.repository }}")/Formula/hive.rb" + brew install "${{ github.repository }}/hive" + test "$(hv --version)" = "${version}" + project="${RUNNER_TEMP}/project" + git init -b main "${project}" + git -C "${project}" config user.name release-test + git -C "${project}" config user.email release-test@example.com + printf 'release gate\n' > "${project}/README.md" + git -C "${project}" add README.md + git -C "${project}" commit -m initial + hv init "${project}" + service_definition="${HOME}/Library/LaunchAgents/org.hive.daemon.plist" + test -e "${service_definition}" + grep -F "$(brew --prefix "${{ github.repository }}/hive")/bin/hv" "${service_definition}" + mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output" + printf preserved > "${project}/.agents/skills/hive/SKILL.md" + printf preserved > "${project}/completed-output/result" + hv update + hv uninstall + test ! -e "$(brew --prefix)/bin/hv" + test ! -e "${service_definition}" + test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved + test "$(cat "${project}/completed-output/result")" = preserved + + tier1-aur: + needs: [qualify, candidate] + runs-on: ubuntu-22.04 + container: archlinux:latest + steps: + - run: pacman -Syu --noconfirm base-devel git curl + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v5 + with: + name: qualified-release-candidate + path: candidate + - name: AE2 AUR build, update delegation, and removal gate + run: | + version="${{ needs.qualify.outputs.version }}" + cp -R candidate/generated/aur "${RUNNER_TEMP}/aur" + archive="${PWD}/candidate/dist/hive-${version}-linux-x86_64.tar.gz" + sed -i "s|https://github.com/.*/hive-${version}-linux-x86_64.tar.gz|file://${archive}|" \ + "${RUNNER_TEMP}/aur/PKGBUILD" + useradd -m builder + chown -R builder:builder "${RUNNER_TEMP}/aur" + runuser -u builder -- bash -lc "cd '${RUNNER_TEMP}/aur' && makepkg --noconfirm" + pacman -U --noconfirm "${RUNNER_TEMP}/aur/"hive-bin-*.pkg.tar.* + test "$(hv --version)" = "${version}" + mkdir -p "${RUNNER_TEMP}/fake-bin" + printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/fake-bin/systemctl" + printf '#!/bin/sh\nif [ "$1" = "-S" ]; then printf delegated > "%s"; exit 0; fi\nexec pacman "$@"\n' \ + "${RUNNER_TEMP}/yay-called" > "${RUNNER_TEMP}/fake-bin/yay" + chmod 0755 "${RUNNER_TEMP}/fake-bin/systemctl" "${RUNNER_TEMP}/fake-bin/yay" + export XDG_CONFIG_HOME="${RUNNER_TEMP}/config" + export XDG_DATA_HOME="${RUNNER_TEMP}/data" + export XDG_STATE_HOME="${RUNNER_TEMP}/state" + export XDG_CACHE_HOME="${RUNNER_TEMP}/cache" + export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime" + export PATH="${RUNNER_TEMP}/fake-bin:${PATH}" + project="${RUNNER_TEMP}/project" + git init -b main "${project}" + git -C "${project}" config user.name release-test + git -C "${project}" config user.email release-test@example.com + printf 'release gate\n' > "${project}/README.md" + git -C "${project}" add README.md + git -C "${project}" commit -m initial + hv init "${project}" + service_definition="${XDG_CONFIG_HOME}/systemd/user/hive.service" + test -e "${service_definition}" + grep -F /usr/bin/hv "${service_definition}" + mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output" + printf preserved > "${project}/.agents/skills/hive/SKILL.md" + printf preserved > "${project}/completed-output/result" + hv update + test "$(cat "${RUNNER_TEMP}/yay-called")" = delegated + hv uninstall + test ! -e /usr/bin/hv + test ! -e "${service_definition}" + test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved + test "$(cat "${project}/completed-output/result")" = preserved + + publish-skills: + needs: [qualify, tier1-shell, tier1-homebrew, tier1-aur] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + path: hive + - uses: actions/checkout@v6 + with: + repository: ivankuznetsov/hive-skills + token: ${{ secrets.HIVE_SKILLS_TOKEN }} + path: skills + - name: Publish the separately versioned compatible skills tree + run: | + test "$(ruby -Ihive/lib -rhive/version -e 'print Hive::VERSION')" = \ + "$(ruby -rjson -e 'print JSON.parse(File.read("hive/hive-skills/package.json")).fetch("version")')" + rsync -a --delete --exclude .git hive/hive-skills/ skills/ + git -C skills config user.name github-actions + git -C skills config user.email github-actions@github.com + git -C skills add -A + git -C skills diff --cached --quiet || git -C skills commit -m "release: hive-skills ${GITHUB_REF_NAME}" + ! git -C skills ls-remote --exit-code --tags origin "refs/tags/${GITHUB_REF_NAME}" >/dev/null 2>&1 + git -C skills tag "${GITHUB_REF_NAME}" + git -C skills push origin HEAD "${GITHUB_REF_NAME}" + + release: + needs: [qualify, candidate, tier1-shell, tier1-homebrew, tier1-aur, publish-skills] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v5 + with: + name: qualified-release-candidate + path: candidate + - name: Publish immutable qualified release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" candidate/dist/* candidate/build/*.spdx.json \ + --verify-tag --title "Hive ${GITHUB_REF_NAME}" --generate-notes + - uses: actions/upload-artifact@v4 + with: + name: canonical-release-metadata + path: candidate/dist/ + + publish-downstream: + needs: release + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + path: hive + - uses: actions/download-artifact@v5 + with: + name: canonical-release-metadata + path: hive/dist + - uses: actions/checkout@v6 + with: + repository: ${{ vars.HOMEBREW_TAP_REPOSITORY }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + - name: Check out AUR package with pinned host identity + env: + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + run: | + install -Dm600 /dev/stdin ~/.ssh/id_ed25519 <<<"${AUR_SSH_KEY}" + mkdir -p ~/.ssh + ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "${RUNNER_TEMP}/aur-known-host" + test "$(ssh-keygen -E sha256 -lf "${RUNNER_TEMP}/aur-known-host" | awk '{print $2}')" = \ + "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4" + install -m600 "${RUNNER_TEMP}/aur-known-host" ~/.ssh/known_hosts + git clone "ssh://aur@aur.archlinux.org/${{ vars.AUR_REPOSITORY }}.git" aur + - name: Verify public bytes, render, and publish both native channels + run: | + cd hive + HOMEBREW_TAP_DIR=../tap AUR_REPO_DIR=../aur \ + scripts/publish-packages --manifest dist/release-manifest.json + for repo in ../tap ../aur; do + git -C "${repo}" config user.name github-actions + git -C "${repo}" config user.email github-actions@github.com + git -C "${repo}" add -A + git -C "${repo}" diff --cached --quiet || \ + git -C "${repo}" commit -m "hive ${GITHUB_REF_NAME}" + git -C "${repo}" push + done diff --git a/README.md b/README.md index 1fd7a0ff..d066446c 100644 --- a/README.md +++ b/README.md @@ -48,30 +48,11 @@ Hive's other primary surface is a coding agent — Claude Code, Codex, Gemini, P ### Install Hive via an agent -Paste this into Claude Code, Codex, or another agent CLI when you want it to install Hive for you. The block has explicit stop-conditions so the agent halts before clobbering existing state. - -```text -Install Hive from the canonical GitHub source into ~/Dev/hive and put the hive binary on PATH. - -Before changing anything: -- If ~/Dev/hive already exists, stop and ask whether to reuse it, pull it, or choose another directory. -- If `claude` is missing or `claude --version` is older than 2.1.118, stop and report the missing prerequisite. -- If `codex` is missing or `codex --version` is older than 0.125.0, stop and report that the default execute agent will not work until Codex is installed. -- If `gh auth status` fails, stop and ask the user to authenticate GitHub CLI. -- If ~/.local/bin is not on PATH, stop and ask which PATH directory should receive the symlink, then substitute that directory for ~/.local/bin in the link command below. -- If /hive already exists (file, symlink, or another checkout's binary), stop and ask whether to overwrite it or pick a different bin directory before running the link command below. - -Run these commands in order (replace with the chosen PATH directory; default is ~/.local/bin): - -git clone https://github.com/ivankuznetsov/hive ~/Dev/hive -cd ~/Dev/hive -bundle install -mkdir -p -ln -sf ~/Dev/hive/bin/hive /hive -hive --version - -Report the installed version and the path returned by `command -v hive`. -``` +Use the unchanged, consent-gated prompt in +[docs/install-with-agent.md](docs/install-with-agent.md). It detects macOS, +Ubuntu, or Arch; selects Homebrew, AUR, or the verified shell channel; preserves +a foreign `hive` command by using `hv`; confirms the project root before init; +and treats the separate agent-skill install as another approval boundary. ### Operate Hive day-to-day via an agent @@ -86,17 +67,16 @@ The `--json` envelope is stable across versions (schemas live under [schemas/](s ## Install -Requires Ruby 3.4, git ≥ 2.40, an authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, and an authenticated `gh`. See [docs/getting-started.md#prerequisites](docs/getting-started.md#prerequisites) for the full prerequisite list and the first-run walkthrough. +Release installs are self-contained and require no host Ruby: -```bash -git clone https://github.com/ivankuznetsov/hive ~/Dev/hive -cd ~/Dev/hive -bundle install -mkdir -p ~/.local/bin -ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive -``` +- Homebrew tap on macOS arm64 +- AUR package `hive-bin` on Arch x86_64 +- checksum-verifying shell installer on macOS arm64 and Linux x86_64 -If `~/.local/bin` is not on `PATH`, put the symlink in a directory that is. The `-sf` form overwrites an existing `hive` at the target path; run `command -v hive` first if you already have one installed and don't want to clobber it. Verify the install with `hive --version` and `hive doctor`. +Every channel installs `hv`; it exposes `hive` only when that name is +unclaimed. See [docs/install.md](docs/install.md) for commands, XDG paths, +dependency diagnostics, service consent, and the separate `hive-skills` +package. Source-checkout development still uses Ruby 3.4 and Bundler. ## Power-User / Scripting CLI diff --git a/Rakefile b/Rakefile index 9f5959cd..764def85 100644 --- a/Rakefile +++ b/Rakefile @@ -40,6 +40,14 @@ namespace :e2e do end end +Rake::TestTask.new(:distribution) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/{release,install,acceptance}/**/*_test.rb"] + t.warning = false + t.description = "Run release, installer, package, prompt, and safety contracts" +end + desc "Run real-subprocess CLI/TUI e2e scenarios" task :e2e do ruby "bin/hive-e2e", "run" diff --git a/assets/project/README.md b/assets/project/README.md new file mode 100644 index 00000000..c27e4d33 --- /dev/null +++ b/assets/project/README.md @@ -0,0 +1,5 @@ +# Hive project configuration + +Files in this directory are generated conservatively by `hive init`. Hive +never overwrites an edited file and tracks only generated files in +`ownership.yml`. diff --git a/assets/project/settings.yml b/assets/project/settings.yml new file mode 100644 index 00000000..7e4ba08a --- /dev/null +++ b/assets/project/settings.yml @@ -0,0 +1,2 @@ +version: 1 +pipeline_state: ../.hive-state diff --git a/assets/services/hive.service b/assets/services/hive.service new file mode 100644 index 00000000..35a5249e --- /dev/null +++ b/assets/services/hive.service @@ -0,0 +1,11 @@ +[Unit] +Description=Hive pipeline daemon + +[Service] +Type=simple +ExecStart=@EXECUTABLE@ daemon start +Restart=on-failure +Environment=HIVE_STATE_DIR=@STATE_DIR@ + +[Install] +WantedBy=default.target diff --git a/assets/services/launchd.plist b/assets/services/launchd.plist new file mode 100644 index 00000000..476ba3f5 --- /dev/null +++ b/assets/services/launchd.plist @@ -0,0 +1,18 @@ + + + + + Labelorg.hive.daemon + ProgramArguments + + @EXECUTABLE@ + daemon + start + + RunAtLoad + KeepAliveSuccessfulExit + StandardOutPath@STATE_DIR@/daemon.log + StandardErrorPath@STATE_DIR@/daemon.log + + diff --git a/assets/shared/release.yml b/assets/shared/release.yml new file mode 100644 index 00000000..6e907490 --- /dev/null +++ b/assets/shared/release.yml @@ -0,0 +1,2 @@ +version: 1 +project_assets: project diff --git a/bin/hive b/bin/hive index bfd53884..297cfe8c 100755 --- a/bin/hive +++ b/bin/hive @@ -1,6 +1,7 @@ #!/usr/bin/env ruby -$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +resource_root = ENV.fetch("HIVE_RESOURCE_ROOT") { File.expand_path("..", __dir__) } +$LOAD_PATH.unshift(File.join(resource_root, "lib")) require "hive" require "hive/cli" diff --git a/docs/install-with-agent.md b/docs/install-with-agent.md new file mode 100644 index 00000000..037927e2 --- /dev/null +++ b/docs/install-with-agent.md @@ -0,0 +1,49 @@ +# Install Hive with an agent + +Paste the following prompt unchanged into Claude Code, Codex, or Pi: + +> Inspect the host without changing it: report `uname -s`, `uname -m`, the +> Linux distribution (when applicable), whether `brew` exists on macOS, +> whether `paru` or `yay` exists on Arch, and whether `hive` or `hv` already +> resolves on PATH. Stop with an explanation unless this is macOS arm64, +> Ubuntu 22.04+ x86_64, or Arch x86_64. +> +> Install requested core release `0.1.0` from repository +> `ivankuznetsov/hive`. Select exactly one channel: run +> `brew install ivankuznetsov/hive/hive` on macOS when Homebrew is present; +> install AUR package `hive-bin` through `paru` or `yay` on Arch when one is +> present; otherwise download +> `https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh`, +> show it to the user, and run it with `--version 0.1.0` as the +> checksum-verifying Hive shell installer. Do not install Homebrew, an AUR +> helper, Ruby, Git, Bash, jq, gh, Claude Code, Codex, Pi, or any other +> third-party dependency. Show the exact commands and selected channel, then +> ask for approval before installing. +> +> After installation, resolve both `hive` and `hv`. Preserve any pre-existing +> foreign `hive`; use `hv` for every remaining step when there is a conflict. +> Run the selected command with `--version` and require the requested release +> version before continuing. +> +> Ask whether to initialize this project. If approved, show and confirm the +> absolute Git project root, then run ` init `. Do not +> initialize `/`, the home directory, a parent workspace, or an unconfirmed +> directory. Explain the dependency diagnostics and daemon-start consent +> result. +> +> Finally, ask separately whether to install Hive skill release `0.1.0` from +> `ivankuznetsov/hive-skills`. If approved, use the current client's native +> mechanism: `claude plugin marketplace add +> ivankuznetsov/hive-skills@v0.1.0` then `claude plugin install +> hive-skills@hive-skills`; `codex plugin marketplace add +> ivankuznetsov/hive-skills --ref v0.1.0` then `codex plugin add +> hive-skills@hive-skills`; or `pi install +> git:github.com/ivankuznetsov/hive-skills@v0.1.0`. Show the selected commands +> first and require the skill version to match core `0.1.0`. If the current +> client has no verified native mechanism, skip this step and explain why. +> Never copy files directly into private agent directories. Report the core +> version, selected command, channel, init result, service state, and skill +> result. + +The prompt deliberately separates core installation, project initialization, +and skill installation into three consent boundaries. diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..670fe3ce --- /dev/null +++ b/docs/install.md @@ -0,0 +1,29 @@ +# Install Hive + +Hive v0.1 supports: + +- macOS arm64 through the configured Homebrew tap +- Arch x86_64 through AUR package `hive-bin` +- macOS arm64 and Linux x86_64 through the verified shell installer + +All channels install the same self-contained release payload and require no +host Ruby. `hv` is always installed. `hive` is linked only when that name is +not already owned by another command. + +The shell channel is: + +```sh +curl -fsSLO https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh +less install.sh +bash install.sh --version 0.1.0 +``` + +The installer verifies the release manifest and archive SHA-256 before +replacement. It uses `${HIVE_BIN_DIR:-$HOME/.local/bin}` and writes its receipt +under `${XDG_DATA_HOME:-$HOME/.local/share}/hive`. + +After installation, run `hive init /absolute/project` (or `hv init ...` after a +name conflict). Init creates versioned XDG data, a conservative `.hive/` +scaffold, the existing `.hive-state` pipeline worktree, and a stopped user +service definition. Starting the service requires the init consent prompt. +Agent skills are distributed separately through `hive-skills`. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..a5a25440 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,26 @@ +# Release Hive + +Tags are strict immutable semver (`vX.Y.Z`) and the embedded +`Hive::VERSION` must match. The release workflow: + +1. Requires both native channel repositories and their credentials, then runs + Ruby/distribution/lint plus native installs with the current Claude, Codex, + and Pi clients. +2. Builds Tebako executables on macOS arm64 and Ubuntu 22.04 x86_64, then runs + packaged `--version` smoke checks without relying on host Ruby. +3. Packages both targets and authenticated shell helpers, writes + `release-manifest.json`, `SHA256SUMS`, and SPDX SBOMs, then attests the + actual release archives. +4. Runs shell lifecycle gates on macOS/Linux, a Homebrew lifecycle on macOS, + and an AUR build/update/uninstall lifecycle on Arch. +5. Publishes the matching `ivankuznetsov/hive-skills` tag, then refuses to + mutate an existing core release and publishes the immutable GitHub assets. +6. Downloads every public asset and verifies its size and SHA-256 before + rendering and pushing both the Homebrew tap and AUR metadata. + +Configure `HOMEBREW_TAP_REPOSITORY`, `AUR_REPOSITORY`, +`HOMEBREW_TAP_TOKEN`, `AUR_SSH_KEY`, and `HIVE_SKILLS_TOKEN` before tagging. +The AUR checkout accepts only its pinned ED25519 host fingerprint. Rehearse +with a disposable prerelease tag. +Tier-1 failures block publication; tier-2 jobs, when added, must remain clearly +labelled best-effort. diff --git a/docs/uninstall.md b/docs/uninstall.md new file mode 100644 index 00000000..cb99d943 --- /dev/null +++ b/docs/uninstall.md @@ -0,0 +1,19 @@ +# Uninstall Hive + +Run `hive uninstall` (or `hv uninstall`) to validate the installation receipt, +stop and unregister the user service, and delegate core removal to Homebrew, +`paru`/`yay`, or the shell channel. + +By default, all user work is preserved: + +- XDG config, data, cache, and state +- `.hive-state` tasks, logs, completed outputs, and worktrees +- every project `.hive/` file +- all agent skills and marketplace packages + +To remove generated project scaffold files, pass an absolute project path with +`--project-cleanup`. `--purge` makes that same narrow cleanup +non-interactive. Both modes remove only regular, unmodified files whose digest +matches `.hive/ownership.yml`. Modified files, unknown files, symlinks, +path-escaping entries, state, completed work, and agent skills remain +preserved. Hive never recursively deletes `.hive/` or `.hive-state/`. diff --git a/hive-skills/.agents/plugins/marketplace.json b/hive-skills/.agents/plugins/marketplace.json new file mode 100644 index 00000000..7d18a57b --- /dev/null +++ b/hive-skills/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "hive-skills", + "interface": { + "displayName": "Hive Skills" + }, + "plugins": [ + { + "name": "hive-skills", + "source": { + "source": "local", + "path": "./plugins/hive-skills" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/hive-skills/.claude-plugin/marketplace.json b/hive-skills/.claude-plugin/marketplace.json new file mode 100644 index 00000000..518ee3a9 --- /dev/null +++ b/hive-skills/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "hive-skills", + "version": "0.1.0", + "description": "Agent workflows for Hive delivery pipelines.", + "owner": { + "name": "Hive maintainers" + }, + "plugins": [ + { + "name": "hive-skills", + "source": "./plugins/hive-skills", + "description": "Safe Hive pipeline operations.", + "version": "0.1.0", + "category": "productivity" + } + ] +} diff --git a/hive-skills/marketplaces/adapters.json b/hive-skills/marketplaces/adapters.json new file mode 100644 index 00000000..18595ba1 --- /dev/null +++ b/hive-skills/marketplaces/adapters.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "version": "0.1.0", + "adapters": { + "claude": [ + "claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0", + "claude plugin install hive-skills@hive-skills" + ], + "codex": [ + "codex plugin marketplace add ivankuznetsov/hive-skills --ref v0.1.0", + "codex plugin add hive-skills@hive-skills" + ], + "pi": [ + "pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0" + ] + } +} diff --git a/hive-skills/marketplaces/claude/contract.json b/hive-skills/marketplaces/claude/contract.json new file mode 100644 index 00000000..7ae8d153 --- /dev/null +++ b/hive-skills/marketplaces/claude/contract.json @@ -0,0 +1,4 @@ +{ + "marketplace": ".claude-plugin/marketplace.json", + "plugin": "plugins/hive-skills/.claude-plugin/plugin.json" +} diff --git a/hive-skills/marketplaces/codex/contract.json b/hive-skills/marketplaces/codex/contract.json new file mode 100644 index 00000000..2bf23fc8 --- /dev/null +++ b/hive-skills/marketplaces/codex/contract.json @@ -0,0 +1,4 @@ +{ + "marketplace": ".agents/plugins/marketplace.json", + "plugin": "plugins/hive-skills/.codex-plugin/plugin.json" +} diff --git a/hive-skills/marketplaces/pi/contract.json b/hive-skills/marketplaces/pi/contract.json new file mode 100644 index 00000000..1e77b79c --- /dev/null +++ b/hive-skills/marketplaces/pi/contract.json @@ -0,0 +1,4 @@ +{ + "manifest": "package.json", + "skills": "skills" +} diff --git a/hive-skills/package.json b/hive-skills/package.json new file mode 100644 index 00000000..228e370b --- /dev/null +++ b/hive-skills/package.json @@ -0,0 +1,9 @@ +{ + "name": "hive-skills", + "version": "0.1.0", + "private": true, + "keywords": ["pi-package"], + "pi": { + "skills": ["./skills"] + } +} diff --git a/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json b/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json new file mode 100644 index 00000000..2162c429 --- /dev/null +++ b/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "hive-skills", + "version": "0.1.0", + "description": "Safe agent workflows for operating Hive delivery pipelines.", + "author": { + "name": "Hive maintainers" + }, + "license": "MIT", + "skills": "./skills/" +} diff --git a/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json b/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json new file mode 100644 index 00000000..55d4db3d --- /dev/null +++ b/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "hive-skills", + "version": "0.1.0", + "description": "Safe agent workflows for operating Hive delivery pipelines.", + "author": { + "name": "Hive maintainers" + }, + "license": "MIT", + "keywords": ["hive", "delivery", "workflow"], + "skills": "./skills/", + "interface": { + "displayName": "Hive Skills", + "shortDescription": "Operate Hive pipelines from Codex.", + "longDescription": "Inspect, diagnose, and advance Hive delivery tasks while preserving approval and state boundaries.", + "developerName": "Hive maintainers", + "category": "Productivity", + "capabilities": ["Interactive"], + "defaultPrompt": ["Inspect this project's Hive status and recommend the next safe action."] + } +} diff --git a/hive-skills/plugins/hive-skills/skills/hive/SKILL.md b/hive-skills/plugins/hive-skills/skills/hive/SKILL.md new file mode 100644 index 00000000..f1fbd01f --- /dev/null +++ b/hive-skills/plugins/hive-skills/skills/hive/SKILL.md @@ -0,0 +1,21 @@ +--- +name: hive +description: Operate an initialized Hive software-delivery pipeline. Use when an agent needs to inspect Hive status, create work, advance a task, diagnose a blocked stage, or manage the Hive daemon; do not use to install Hive or copy agent skills. +--- + +# Hive + +1. Resolve `hive`, falling back to `hv` when `hive` is absent or foreign. +2. Run ` status --json` before choosing an action. +3. Prefer the workflow verbs reported by status (`brainstorm`, `plan`, + `develop`, `open-pr`, `review`, `finalize`, `archive`) over moving stage + directories directly. +4. Show the exact target and state-changing command before running it. Respect + waiting markers and explicit user approval gates. +5. Use ` status --diagnose ` for red rows before changing state. + Clear only the named recovery marker recommended by the diagnostic. +6. Never edit `plan.md`, `worktree.yml`, generated reviews, or pipeline markers + to manufacture completion. Never delete `.hive-state`, completed outputs, or + unknown `.hive/` content. +7. For daemon work, check ` daemon status` first. Do not enable, + start, stop, update, or uninstall without explicit user approval. diff --git a/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml b/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml new file mode 100644 index 00000000..83589f42 --- /dev/null +++ b/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Hive" + short_description: "Operate Hive delivery pipelines safely" + default_prompt: "Use $hive to inspect this project and advance the appropriate Hive workflow." diff --git a/hive-skills/skills/hive/SKILL.md b/hive-skills/skills/hive/SKILL.md new file mode 100644 index 00000000..f1fbd01f --- /dev/null +++ b/hive-skills/skills/hive/SKILL.md @@ -0,0 +1,21 @@ +--- +name: hive +description: Operate an initialized Hive software-delivery pipeline. Use when an agent needs to inspect Hive status, create work, advance a task, diagnose a blocked stage, or manage the Hive daemon; do not use to install Hive or copy agent skills. +--- + +# Hive + +1. Resolve `hive`, falling back to `hv` when `hive` is absent or foreign. +2. Run ` status --json` before choosing an action. +3. Prefer the workflow verbs reported by status (`brainstorm`, `plan`, + `develop`, `open-pr`, `review`, `finalize`, `archive`) over moving stage + directories directly. +4. Show the exact target and state-changing command before running it. Respect + waiting markers and explicit user approval gates. +5. Use ` status --diagnose ` for red rows before changing state. + Clear only the named recovery marker recommended by the diagnostic. +6. Never edit `plan.md`, `worktree.yml`, generated reviews, or pipeline markers + to manufacture completion. Never delete `.hive-state`, completed outputs, or + unknown `.hive/` content. +7. For daemon work, check ` daemon status` first. Do not enable, + start, stop, update, or uninstall without explicit user approval. diff --git a/hive-skills/skills/hive/agents/openai.yaml b/hive-skills/skills/hive/agents/openai.yaml new file mode 100644 index 00000000..83589f42 --- /dev/null +++ b/hive-skills/skills/hive/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Hive" + short_description: "Operate Hive delivery pipelines safely" + default_prompt: "Use $hive to inspect this project and advance the appropriate Hive workflow." diff --git a/lib/hive.rb b/lib/hive.rb index 8db3300a..428254f6 100644 --- a/lib/hive.rb +++ b/lib/hive.rb @@ -1,5 +1,6 @@ +require "hive/version" + module Hive - VERSION = "0.1.0".freeze MIN_CLAUDE_VERSION = "2.1.118".freeze module Schemas diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb new file mode 100644 index 00000000..2277abf9 --- /dev/null +++ b/lib/hive/assets.rb @@ -0,0 +1,163 @@ +require "digest" +require "fileutils" +require "yaml" +require "hive/paths" + +module Hive + module Assets + MANIFEST = ".hive/ownership.yml".freeze + module_function + + def seed_shared!(source: nil, project_source: nil, + destination: Hive::Paths.releases_dir, version: Hive::VERSION) + bundled_source = source.nil? + source ||= Hive::Paths.resource("assets", "shared") + project_source ||= Hive::Paths.resource("assets", "project") if bundled_source + target = File.join(destination, version) + if complete_release?(target) + switch_current!(destination, version) + return target + end + + FileUtils.mkdir_p(destination) + temporary = File.join(destination, ".#{version}.tmp-#{Process.pid}") + FileUtils.rm_rf(temporary) if File.exist?(temporary) + FileUtils.mkdir_p(temporary) + copy_tree(source, temporary) + copy_tree(project_source, File.join(temporary, "project")) if project_source + File.write(File.join(temporary, ".complete"), "#{version}\n") + File.rename(temporary, target) + switch_current!(destination, version) + target + ensure + FileUtils.rm_rf(temporary) if defined?(temporary) && temporary && File.exist?(temporary) + end + + def scaffold_project!(project_root, source: File.join(Hive::Paths.release_dir, "project")) + root = safe_project_root(project_root) + destination = File.join(root, ".hive") + manifest_path = File.join(root, MANIFEST) + ensure_safe_project_destination!(root, destination) + previous = load_manifest(manifest_path) + result = { created: [], conflicts: [] } + managed = previous.fetch("files", {}) + + source_files(source).each do |relative| + target = File.join(destination, relative) + project_relative = File.join(".hive", relative) + source_digest = digest(File.join(source, relative)) + if File.exist?(target) || File.symlink?(target) + if safe_regular_file?(target) && managed[project_relative] == digest(target) && digest(target) == source_digest + next + end + result[:conflicts] << project_relative + next + end + + ensure_safe_parent!(root, File.dirname(target)) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp(File.join(source, relative), target) + managed[project_relative] = digest(target) + result[:created] << project_relative + end + + write_manifest(manifest_path, managed) + result + end + + def safe_project_root(project_root) + root = File.realpath(File.expand_path(project_root)) + unsafe = [ File::SEPARATOR, File.realpath(Dir.home) ] + raise Hive::ConfigError, "refusing unsafe project root: #{root}" if unsafe.include?(root) + raise Hive::ConfigError, "project root is not writable: #{root}" unless File.writable?(root) + + root + rescue Errno::ENOENT + raise Hive::ConfigError, "project root does not exist: #{project_root}" + end + + def source_files(source) + return [] unless File.directory?(source) + + Dir.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .map { |path| path.delete_prefix("#{source}/") } + .sort + end + + def copy_tree(source, destination) + source_files(source).each do |relative| + target = File.join(destination, relative) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp(File.join(source, relative), target) + end + end + + def complete_release?(target) + File.directory?(target) && File.file?(File.join(target, ".complete")) + end + + def switch_current!(destination, version) + pointer = File.join(destination, "current") + if File.exist?(pointer) && !File.symlink?(pointer) + raise Hive::ConfigError, "refusing to replace non-symlink release pointer: #{pointer}" + end + return if File.symlink?(pointer) && File.readlink(pointer) == version + + temporary = File.join(destination, ".current-#{Process.pid}") + File.symlink(version, temporary) + File.rename(temporary, pointer) + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def load_manifest(path) + return { "version" => 1, "files" => {} } unless File.file?(path) + + data = YAML.safe_load_file(path) + data.is_a?(Hash) && data["files"].is_a?(Hash) ? data : { "version" => 1, "files" => {} } + rescue Psych::Exception + { "version" => 1, "files" => {} } + end + + def write_manifest(path, files) + raise Hive::ConfigError, "refusing symlinked ownership manifest: #{path}" if File.symlink?(path) + + FileUtils.mkdir_p(File.dirname(path)) + temporary = "#{path}.tmp-#{Process.pid}" + File.write(temporary, { "version" => 1, "files" => files.sort.to_h }.to_yaml) + File.rename(temporary, path) + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def safe_regular_file?(path) + File.file?(path) && !File.symlink?(path) + end + + def digest(path) + Digest::SHA256.file(path).hexdigest + end + + def ensure_safe_project_destination!(root, destination) + if File.symlink?(destination) + raise Hive::ConfigError, "refusing symlinked project scaffold directory: #{destination}" + end + return unless File.exist?(destination) && !File.directory?(destination) + + raise Hive::ConfigError, "project scaffold path is not a directory: #{destination}" + end + + def ensure_safe_parent!(root, parent) + hive_root = File.join(root, ".hive") + current = parent + while current.start_with?("#{hive_root}#{File::SEPARATOR}") || current == hive_root + raise Hive::ConfigError, "refusing symlinked project scaffold parent: #{current}" if File.symlink?(current) + + break if current == hive_root + + current = File.dirname(current) + end + end + end +end diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index 9bc60cb8..c697e21f 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -63,9 +63,18 @@ 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, desc: "first-run provider choice" + option :model, type: :string, desc: "first-run model choice" + option :credential_env, type: :string, desc: "environment variable containing provider credentials" 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] + ).call end desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)" @@ -92,6 +101,25 @@ module Hive Hive::Commands::Forget.new(name, json: options[:json]).call end + desc "update", "Update Hive through its owning installation channel" + def update + require "hive/commands/update" + Hive::Commands::Update.new.call + end + + desc "uninstall [PROJECT_PATH]", "Unregister Hive and remove the core through its owning channel" + option :project_cleanup, type: :boolean, default: false, + desc: "remove only unmodified generated .hive files" + option :purge, type: :boolean, default: false, + desc: "non-interactively remove known generated .hive files" + def uninstall(project_path = nil) + require "hive/commands/uninstall" + Hive::Commands::Uninstall.new( + project_root: project_path, + cleanup: options[:project_cleanup] || options[:purge] + ).call + end + desc "prune", "Drop registry entries whose project path no longer exists" long_desc <<~DESC Walks ~/Dev/hive/config.yml and removes every `registered_projects` diff --git a/lib/hive/command_name.rb b/lib/hive/command_name.rb new file mode 100644 index 00000000..2455152d --- /dev/null +++ b/lib/hive/command_name.rb @@ -0,0 +1,35 @@ +module Hive + module CommandName + module_function + + def select(path: ENV.fetch("PATH", ""), installed_executable: $PROGRAM_NAME) + resolved = resolve("hive", path) + return "hive" unless resolved + return "hive" if same_file?(resolved, installed_executable) + + "hv" + end + + def resolve(command, path = ENV.fetch("PATH", "")) + path.split(File::PATH_SEPARATOR).each do |directory| + candidate = File.expand_path(command, directory.empty? ? "." : directory) + return candidate if File.file?(candidate) && File.executable?(candidate) + end + nil + end + + def stable_executable(path: ENV.fetch("PATH", ""), current: $PROGRAM_NAME) + command = select(path: path, installed_executable: current) + resolve(command, path) || resolve(File.basename(current), path) || File.expand_path(current) + end + + def same_file?(left, right) + return false unless right && File.exist?(right) + + File.identical?(left, right) + rescue Errno::ENOENT + false + end + private_class_method :same_file? + end +end diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index f88bca6f..bd576a00 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -44,14 +44,17 @@ 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: nil) @subcommand = subcommand @target = target @detach = detach @dry_run = dry_run @all = all @json = json - @hive_home = hive_home + legacy_runtime_root = hive_home || ENV["HIVE_HOME"] + legacy_runtime_root = nil if legacy_runtime_root&.empty? + @hive_home = legacy_runtime_root || Hive::Config.hive_home + @legacy_runtime_root = legacy_runtime_root end def call @@ -72,11 +75,19 @@ module Hive end def pid_file - @pid_file ||= File.join(@hive_home, ".daemon.pid") + @pid_file ||= if @legacy_runtime_root + File.join(@legacy_runtime_root, ".daemon.pid") + else + File.join(Hive::Paths.runtime_dir, "daemon.pid") + end end def log_file - @log_file ||= File.join(@hive_home, "logs", "daemon.log") + @log_file ||= if @legacy_runtime_root + File.join(@legacy_runtime_root, "logs", "daemon.log") + else + File.join(Hive::Paths.state_dir, "logs", "daemon.log") + end end private @@ -84,6 +95,7 @@ module Hive def start_daemon warn_unsupported_json_flag if @json FileUtils.mkdir_p(@hive_home) + FileUtils.mkdir_p(File.dirname(pid_file)) FileUtils.mkdir_p(File.dirname(log_file)) # Single-instance check: if a live daemon already owns the PID diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb index b86e8d61..2c59c699 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -5,13 +5,19 @@ require "hive/config" require "hive/git_ops" require "hive/commands/init/prompts" require "hive/commands/doctor" +require "hive/assets" +require "hive/dependency_check" +require "hive/service" module Hive module Commands class Init - def initialize(project_path, force: false, prompts: nil) + def initialize(project_path, force: false, prompts: nil, provider: nil, model: nil, credential_env: nil) @project_path = File.expand_path(project_path) @force = force + @provider = provider + @model = model + @credential_env = credential_env # 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,14 +28,15 @@ module Hive end def call + print_dependency_diagnostics 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}" + repair_existing_install!(ops) + return end + validate_clean_tree! unless @force # Prompt placement is load-bearing (per ADR-023): runs AFTER the # already-initialized guard above, BEFORE any disk writes below. @@ -37,13 +44,73 @@ module Hive # no orphan branch, no worktree, no master .gitignore update — # so a re-run of `hive init` proceeds normally. answers = collect_prompt_answers + provider_values = provider_values(answers) + Hive::Config.provider_config_data(**provider_values) + validate_project_destination! + Hive::Config.write_provider_config!(**provider_values) ops.hive_state_init write_per_project_config(ops, answers: answers) + seed_install_assets! ops.add_hive_state_to_master_gitignore! entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path) + register_user_service!(start: answers.fetch("daemon_enabled", false)) + + print_summary(entry: entry, ops: ops) + run_init_preflight! + end + + def seed_install_assets! + legacy_home = ENV["HIVE_HOME"] + destination = legacy_home && !legacy_home.empty? ? File.join(legacy_home, "data", "releases") : Hive::Paths.releases_dir + release = Hive::Assets.seed_shared!(destination: destination) + result = Hive::Assets.scaffold_project!(@project_path, source: File.join(release, "project")) + result[:conflicts].each { |path| write_warn("hive: preserving existing or modified #{path}") } + end + + def provider_values(answers) + supplied = [ @provider, @model, @credential_env ] + if supplied.any? && !supplied.all? + raise Hive::ConfigError, "provider, model, and credential-env must be supplied together" + end + + { + provider: @provider || answers.fetch("provider"), + model: @model || answers.fetch("model"), + credential_env: @credential_env || answers.fetch("credential_env") + } + end + def validate_project_destination! + root = Hive::Assets.safe_project_root(@project_path) + Hive::Assets.ensure_safe_project_destination!(root, File.join(root, ".hive")) + end + + def print_dependency_diagnostics + Hive::DependencyCheck.call.each do |row| + next unless row[:status] == :missing + + write_warn("hive: dependency #{row[:name]} is missing. #{row[:hint]}") + end + end + + def register_user_service!(start:) + return if ENV["HIVE_HOME"] && !ENV["HIVE_HOME"].empty? + + service = Hive::Service.current + service.register + service.start if start + end + + def repair_existing_install!(ops) + ops.hive_state_init + validate_project_destination! + seed_install_assets! + config = Hive::Config.load(@project_path) + ops.add_hive_state_to_master_gitignore! + entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path) + register_user_service!(start: config.dig("daemon", "enabled") == true) print_summary(entry: entry, ops: ops) run_init_preflight! end diff --git a/lib/hive/commands/init/prompts.rb b/lib/hive/commands/init/prompts.rb index 088635f9..65d364ba 100644 --- a/lib/hive/commands/init/prompts.rb +++ b/lib/hive/commands/init/prompts.rb @@ -26,6 +26,9 @@ module Hive DEFAULT_PLANNING_AGENT = "claude".freeze DEFAULT_DEVELOPMENT_AGENT = "codex".freeze DEFAULT_TRIAGE_BIAS = "courageous".freeze + DEFAULT_PROVIDER = "openai".freeze + DEFAULT_MODEL = "gpt-5".freeze + DEFAULT_CREDENTIAL_ENV = "OPENAI_API_KEY".freeze TRIAGE_BIASES = %w[courageous safetyist].freeze # Reviewer entries shipped in templates/project_config.yml.erb. The @@ -109,6 +112,9 @@ module Hive triage_bias = prompt_triage_bias budgets, timeouts = prompt_limits daemon_enabled = prompt_daemon_enabled + provider = prompt_value("Provider", DEFAULT_PROVIDER) + model = prompt_value("Model", DEFAULT_MODEL) + credential_env = prompt_value("Credential environment variable", DEFAULT_CREDENTIAL_ENV) answers = { "planning_agent" => planning, @@ -117,7 +123,10 @@ module Hive "triage_bias" => triage_bias, "budgets" => budgets, "timeouts" => timeouts, - "daemon_enabled" => daemon_enabled + "daemon_enabled" => daemon_enabled, + "provider" => provider, + "model" => model, + "credential_env" => credential_env } summarize(answers) @@ -143,7 +152,10 @@ module Hive "triage_bias" => DEFAULT_TRIAGE_BIAS, "budgets" => default_budgets, "timeouts" => default_timeouts, - "daemon_enabled" => true + "daemon_enabled" => false, + "provider" => DEFAULT_PROVIDER, + "model" => DEFAULT_MODEL, + "credential_env" => DEFAULT_CREDENTIAL_ENV } # Goes to @summary_io (stdout by default) so a non-TTY caller's # `summary=$(hive init)` capture has a parseable single line. @@ -151,7 +163,8 @@ module Hive "hive: using defaults — planning=#{DEFAULT_PLANNING_AGENT}, " \ "dev=#{DEFAULT_DEVELOPMENT_AGENT}, " \ "reviewers=all#{DEFAULT_REVIEWER_NAMES.size}, " \ - "triage=#{DEFAULT_TRIAGE_BIAS}, limits=defaults, daemon=enabled" + "triage=#{DEFAULT_TRIAGE_BIAS}, limits=defaults, daemon=disabled, " \ + "provider=#{DEFAULT_PROVIDER}, model=#{DEFAULT_MODEL}" ) answers end @@ -352,6 +365,16 @@ module Hive end end + def prompt_value(label, default) + loop do + @output.print "#{label} [#{default}]: " + @output.flush + answer = read_line + return default if answer.empty? + return answer unless answer.match?(/[\r\n]/) + end + end + def summarize(answers) @output.puts "" @output.puts "Summary:" @@ -361,6 +384,9 @@ module Hive @output.puts " triage_bias = #{answers['triage_bias']}" @output.puts " limits = #{summarize_limits(answers)}" @output.puts " daemon = #{answers['daemon_enabled'] ? 'enabled' : 'disabled'}" + @output.puts " provider = #{answers['provider']}" + @output.puts " model = #{answers['model']}" + @output.puts " credential_source = env:#{answers['credential_env']}" end def summarize_limits(answers) diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb new file mode 100644 index 00000000..5abe1fdd --- /dev/null +++ b/lib/hive/commands/uninstall.rb @@ -0,0 +1,101 @@ +require "digest" +require "yaml" +require "hive/install/channel" +require "hive/install/receipt" +require "hive/service" + +module Hive + module Commands + class Uninstall + def initialize(project_root: nil, cleanup: false, receipt_path: Hive::Paths.receipt_file, output: $stdout, + receipt_loader: Hive::Install::Receipt.method(:load), + channel_factory: Hive::Install::Channel.method(:new), service: nil) + @project_root = project_root && File.expand_path(project_root) + @cleanup = cleanup + @receipt_path = receipt_path + @output = output + @receipt_loader = receipt_loader + @channel_factory = channel_factory + @service = service + end + + def call + receipt = @receipt_loader.call(@receipt_path) + channel = @channel_factory.call(receipt) + channel.verify_ownership! + plan = @cleanup && @project_root ? self.class.plan_project_cleanup(@project_root) : empty_cleanup_plan + print_preview(receipt, plan) + (@service || Hive::Service.current).unregister + channel.uninstall + self.class.apply_cleanup_plan(plan) if @cleanup && @project_root + true + end + + def self.cleanup_project(project_root) + apply_cleanup_plan(plan_project_cleanup(project_root)) + end + + def self.plan_project_cleanup(project_root) + root = File.realpath(project_root) + manifest_path = File.join(root, ".hive", "ownership.yml") + return { candidates: [], preserved: [] } unless File.file?(manifest_path) + + manifest = YAML.safe_load_file(manifest_path) + files = manifest.is_a?(Hash) && manifest["files"].is_a?(Hash) ? manifest["files"] : {} + files.each_with_object({ candidates: [], preserved: [] }) do |(relative, expected), result| + target = safe_owned_target(root, relative) + if target && File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == expected + result[:candidates] << [ relative, target, expected ] + else + result[:preserved] << relative + end + end + rescue Psych::Exception, Errno::ENOENT + { candidates: [], preserved: [ ".hive/ownership.yml" ] } + end + + def self.apply_cleanup_plan(plan) + plan.fetch(:candidates).each_with_object({ removed: [], preserved: plan.fetch(:preserved).dup }) do |entry, result| + relative, target, expected = entry + if File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == expected + File.delete(target) + result[:removed] << relative + else + result[:preserved] << relative + end + end + end + + def self.safe_owned_target(root, relative) + return unless relative.start_with?(".hive/") + return if File.symlink?(File.join(root, ".hive")) + + target = File.expand_path(relative, root) + prefix = "#{File.join(root, '.hive')}#{File::SEPARATOR}" + return unless target.start_with?(prefix) + + parent = File.dirname(target) + while parent.start_with?(prefix) + return if File.symlink?(parent) + parent = File.dirname(parent) + end + target + end + + private + + def empty_cleanup_plan + { candidates: [], preserved: [] } + end + + def print_preview(receipt, plan) + @output.puts "hive: removing core through #{receipt['channel']} (#{receipt['package']})" + @output.puts "hive: will unregister the user service before core removal" + removals = plan[:candidates].map(&:first) + @output.puts "hive: will remove generated project files: #{removals.join(', ')}" unless removals.empty? + @output.puts "hive: will preserve project entries: #{plan[:preserved].join(', ')}" unless plan[:preserved].empty? + @output.puts "hive: XDG config/state, completed outputs, unknown project files, and agent skills are preserved" + end + end + end +end diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb new file mode 100644 index 00000000..a0cce500 --- /dev/null +++ b/lib/hive/commands/update.rb @@ -0,0 +1,22 @@ +require "hive/install/channel" +require "hive/install/receipt" + +module Hive + module Commands + class Update + def initialize(receipt_path: Hive::Paths.receipt_file, output: $stdout) + @receipt_path = receipt_path + @output = output + end + + def call + receipt = Hive::Install::Receipt.load(@receipt_path) + channel = Hive::Install::Channel.new(receipt) + channel.verify_ownership! + channel.update + @output.puts "hive: update delegated to #{receipt['channel']} (#{receipt['package']})" + true + end + end + end +end diff --git a/lib/hive/config.rb b/lib/hive/config.rb index a67097d8..f586be04 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -1,6 +1,7 @@ require "yaml" require "fileutils" require "hive/agent_profiles" +require "hive/paths" module Hive module Config @@ -170,11 +171,11 @@ module Hive "codex_budget_usd" => 1, "codex_timeout_sec" => 120, "shutdown_grace_sec" => 60, - "pid_file" => "~/Dev/hive/.bot.pid", - "log_file" => "~/Dev/hive/logs/bot.log", + "pid_file" => nil, + "log_file" => nil, "log_max_bytes" => 10_485_760, "log_max_files" => 5, - "last_seen_state_file" => "~/Dev/hive/.bot.last_seen_update_id" + "last_seen_state_file" => nil }, # Auto-rebase pre-step for `hive run` (plan # docs/plans/2026-05-14-001-feat-hive-auto-rebase-stale-worktree-plan.md). @@ -209,13 +210,50 @@ module Hive module_function def hive_home - ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive") + legacy = ENV["HIVE_HOME"] + legacy && !legacy.empty? ? File.expand_path(legacy) : Hive::Paths.config_dir end def global_config_path File.join(hive_home, "config.yml") end + def write_provider_config!(path: Hive::Paths.config_file, provider:, model:, credential_env:) + provider_data = provider_config_data(provider: provider, model: model, credential_env: credential_env) + + FileUtils.mkdir_p(File.dirname(path)) + temporary = "#{path}.tmp-#{Process.pid}" + data = File.file?(path) ? YAML.safe_load_file(path) : {} + raise ConfigError, "provider config at #{path} must be a mapping" unless data.nil? || data.is_a?(Hash) + + data = (data || {}).merge(provider_data) + File.write(temporary, data.to_yaml, mode: "w", perm: 0o600) + File.chmod(0o600, temporary) + File.rename(temporary, path) + path + rescue Psych::Exception => e + raise ConfigError, "provider config at #{path} is not valid YAML: #{e.message}" + rescue SystemCallError => e + raise ConfigError, "provider config at #{path} could not be written: #{e.message}" + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def provider_config_data(provider:, model:, credential_env:) + if provider.to_s.strip.empty? || model.to_s.strip.empty? + raise ConfigError, "provider and model choices must be non-empty" + end + unless credential_env.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/) + raise ConfigError, "credential source must be an environment variable name, not a secret value" + end + + { + "provider" => provider.to_s, + "model" => model.to_s, + "credential_source" => "env:#{credential_env}" + } + end + def hive_state_dir(project_root, hive_state_name = ".hive-state") File.join(project_root, hive_state_name) end @@ -327,7 +365,7 @@ module Hive end # Load and validate the global `daemon` block from - # `~/Dev/hive/config.yml`. Returns the merged Hash (operator + # the XDG global config. Returns the merged Hash (operator # overrides on top of `Config::DEFAULTS["daemon"]`). Used by # `hive daemon start` / `reload` so operator knobs in the global # config (max_concurrent_runs, poll_interval_sec, log_*, etc.) @@ -355,7 +393,7 @@ module Hive merged end - # Load and validate the global `bot` block from ~/Dev/hive/config.yml. + # Load and validate the global `bot` block from the XDG global config. # `require_runtime: true` is used by `hive bot start` so the opt-in # credentials fail loudly there without making read-only commands like # `hive status` require a Telegram token. @@ -386,9 +424,16 @@ module Hive def global_bot_defaults defaults = deep_dup(DEFAULTS["bot"]) - defaults["pid_file"] = File.join(hive_home, ".bot.pid") - defaults["log_file"] = File.join(hive_home, "logs", "bot.log") - defaults["last_seen_state_file"] = File.join(hive_home, ".bot.last_seen_update_id") + legacy = ENV["HIVE_HOME"] + if legacy && !legacy.empty? + defaults["pid_file"] = File.join(hive_home, ".bot.pid") + defaults["log_file"] = File.join(hive_home, "logs", "bot.log") + defaults["last_seen_state_file"] = File.join(hive_home, ".bot.last_seen_update_id") + else + defaults["pid_file"] = File.join(Hive::Paths.runtime_dir, "bot.pid") + defaults["log_file"] = File.join(Hive::Paths.state_dir, "logs", "bot.log") + defaults["last_seen_state_file"] = File.join(Hive::Paths.state_dir, "bot.last_seen_update_id") + end defaults end diff --git a/lib/hive/dependency_check.rb b/lib/hive/dependency_check.rb new file mode 100644 index 00000000..0b063c6f --- /dev/null +++ b/lib/hive/dependency_check.rb @@ -0,0 +1,27 @@ +module Hive + module DependencyCheck + TOOLS = { + "git" => "Install Git with your operating system's package manager.", + "bash" => "Install Bash with your operating system's package manager.", + "claude" => "Install the Claude Code CLI from its official distribution.", + "gh" => "Install GitHub CLI from https://cli.github.com/.", + "jq" => "Install jq with your operating system's package manager." + }.freeze + module_function + + def call(path: ENV.fetch("PATH", "")) + TOOLS.map do |name, hint| + executable = resolve(name, path) + { name: name, status: executable ? :available : :missing, path: executable, hint: executable ? nil : hint } + end + end + + def resolve(name, path) + path.split(File::PATH_SEPARATOR).each do |directory| + candidate = File.join(directory, name) + return candidate if File.file?(candidate) && File.executable?(candidate) + end + nil + end + end +end diff --git a/lib/hive/install/channel.rb b/lib/hive/install/channel.rb new file mode 100644 index 00000000..c8e753ca --- /dev/null +++ b/lib/hive/install/channel.rb @@ -0,0 +1,121 @@ +require "open3" +require "digest" +require "hive/command_name" +require "hive/install/receipt" + +module Hive + module Install + class Channel + def initialize(receipt, runner: Open3.method(:capture3), resolver: Hive::CommandName.method(:resolve)) + @receipt = Hive::Install::Receipt.validate!(receipt) + @runner = runner + @resolver = resolver + end + + def verify_ownership! + case @receipt["channel"] + when "homebrew" + verify_homebrew_ownership! + when "aur" + verify_aur_ownership! + when "shell" + verify_shell_ownership! + end + true + end + + def update + case @receipt["channel"] + when "homebrew" + execute("brew", "upgrade", @receipt["package"]) + when "aur" + helper = aur_helper! + execute(helper, "-S", "--needed", @receipt["package"]) + when "shell" + installer = @receipt["installer"] + raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell installer path is absent") unless installer + + execute(installer, "--update") + end + end + + def uninstall + case @receipt["channel"] + when "homebrew" + execute("brew", "uninstall", @receipt["package"]) + when "aur" + execute(aur_helper!, "-Rns", "--noconfirm", @receipt["package"]) + when "shell" + uninstaller = @receipt["uninstaller"] + raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell uninstaller path is absent") unless uninstaller + + execute(uninstaller, "--yes") + end + end + + private + + def verify_homebrew_ownership! + prefix = execute_capture("brew", "--prefix", @receipt["package"]).strip + expected = File.join(prefix, "bin", "hv") + return if same_file_or_path?(@receipt["executable"], expected) + + raise Hive::ConfigError, + Hive::Install::Receipt.recovery("Homebrew receipt executable does not belong to #{@receipt['package']}") + end + + def verify_aur_ownership! + owner = execute_capture("pacman", "-Qqo", @receipt["executable"]).strip + return if owner == @receipt["package"] + + raise Hive::ConfigError, + Hive::Install::Receipt.recovery("AUR receipt executable is owned by #{owner.inspect}") + end + + def verify_shell_ownership! + { + @receipt["executable"] => @receipt["executable_sha256"], + @receipt["installer"] => @receipt["installer_sha256"], + @receipt["uninstaller"] => @receipt["uninstaller_sha256"] + }.each do |path, expected| + unless File.file?(path) && !File.symlink?(path) && Digest::SHA256.file(path).hexdigest == expected + raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell-managed file failed ownership verification: #{path}") + end + end + end + + def same_file_or_path?(left, right) + return false unless File.file?(left) && File.file?(right) + + File.identical?(left, right) + rescue Errno::ENOENT + false + end + + def aur_helper! + helper = %w[paru yay].filter_map { |name| @resolver.call(name) }.first + raise Hive::ConfigError, "no supported AUR helper found (install paru or yay)" unless helper + + helper + end + + def execute_capture(*command) + out, err, status = @runner.call(*command) + return out if status.success? + + raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}" + rescue Errno::ENOENT + raise Hive::ConfigError, "owning package tool #{command.first} is unavailable" + end + + def execute(*command) + _out, err, status = @runner.call(*command) + return true if status.success? + + raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}" + rescue Errno::ENOENT + raise Hive::ConfigError, "owning package tool #{command.first} is unavailable" + end + end + end +end diff --git a/lib/hive/install/receipt.rb b/lib/hive/install/receipt.rb new file mode 100644 index 00000000..0a716d7d --- /dev/null +++ b/lib/hive/install/receipt.rb @@ -0,0 +1,106 @@ +require "fileutils" +require "yaml" +require "hive/paths" + +module Hive + module Install + module Receipt + CHANNELS = %w[homebrew aur shell].freeze + REQUIRED = %w[schema_version channel package command_name executable release_source managed_files].freeze + SHELL_REQUIRED = %w[ + executable_sha256 installer installer_sha256 uninstaller uninstaller_sha256 + ].freeze + module_function + + def load(path = Hive::Paths.receipt_file) + raise Hive::ConfigError, recovery("install receipt is missing at #{path}") unless File.file?(path) + + data = YAML.safe_load_file(path) + validate!(data) + rescue Psych::Exception => e + raise Hive::ConfigError, recovery("install receipt is malformed: #{e.message}") + end + + def write!(path: Hive::Paths.receipt_file, data:) + receipt = { "schema_version" => 1 }.merge(data) + validate!(receipt) + FileUtils.mkdir_p(File.dirname(path)) + temporary = "#{path}.tmp-#{Process.pid}" + File.write(temporary, receipt.to_yaml, mode: "w", perm: 0o600) + File.chmod(0o600, temporary) + File.rename(temporary, path) + receipt + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def validate!(data) + raise Hive::ConfigError, recovery("install receipt must be a mapping") unless data.is_a?(Hash) + unless data["schema_version"] == 1 + raise Hive::ConfigError, recovery("unsupported install receipt schema") + end + unless CHANNELS.include?(data["channel"]) + raise Hive::ConfigError, recovery("invalid install receipt channel #{data['channel'].inspect}") + end + missing = REQUIRED.reject { |key| present_field?(data[key]) } + raise Hive::ConfigError, recovery("install receipt is missing #{missing.join(', ')}") unless missing.empty? + unless %w[hive hv].include?(data["command_name"]) + raise Hive::ConfigError, recovery("invalid install receipt command name") + end + unless File.basename(data["executable"]) == "hv" + raise Hive::ConfigError, recovery("install receipt executable must be the stable hv command") + end + + validate_managed_files!(data) + validate_shell!(data) if data["channel"] == "shell" + + data + end + + def present_field?(value) + (value.is_a?(String) && !value.empty?) || (value.is_a?(Array) && !value.empty?) || value == 1 + end + private_class_method :present_field? + + def validate_managed_files!(data) + paths = data["managed_files"] + unless paths.is_a?(Array) && paths.all? { |path| absolute_path?(path) } && paths.uniq == paths + raise Hive::ConfigError, recovery("install receipt managed_files must be unique absolute paths") + end + return if absolute_path?(data["executable"]) && paths.include?(data["executable"]) + + raise Hive::ConfigError, recovery("install receipt does not bind its executable to managed_files") + end + private_class_method :validate_managed_files! + + def validate_shell!(data) + missing = SHELL_REQUIRED.reject { |key| data[key].is_a?(String) && !data[key].empty? } + raise Hive::ConfigError, recovery("shell receipt is missing #{missing.join(', ')}") unless missing.empty? + + %w[executable_sha256 installer_sha256 uninstaller_sha256].each do |key| + next if data[key].match?(/\A[a-f0-9]{64}\z/) + + raise Hive::ConfigError, recovery("shell receipt has invalid #{key}") + end + { "installer" => "install.sh", "uninstaller" => "uninstall-shell.sh" }.each do |key, basename| + path = data[key] + unless absolute_path?(path) && File.basename(path) == basename && data["managed_files"].include?(path) + raise Hive::ConfigError, recovery("shell receipt has invalid #{key} ownership") + end + end + end + private_class_method :validate_shell! + + def absolute_path?(path) + path.is_a?(String) && !path.empty? && File.absolute_path(path) == path + rescue ArgumentError + false + end + private_class_method :absolute_path? + + def recovery(message) + "#{message}; reinstall Hive through the original channel to repair ownership" + end + end + end +end diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb new file mode 100644 index 00000000..a668f7c3 --- /dev/null +++ b/lib/hive/paths.rb @@ -0,0 +1,72 @@ +module Hive + module Paths + module_function + + def config_dir + File.join(xdg_root("XDG_CONFIG_HOME", ".config"), "hive") + end + + def data_dir + File.join(xdg_root("XDG_DATA_HOME", ".local/share"), "hive") + end + + def state_dir + service_state = ENV["HIVE_STATE_DIR"] + return File.expand_path(service_state) if service_state && !service_state.empty? + + File.join(xdg_root("XDG_STATE_HOME", ".local/state"), "hive") + end + + def cache_dir + File.join(xdg_root("XDG_CACHE_HOME", ".cache"), "hive") + end + + def runtime_dir + root = ENV["XDG_RUNTIME_DIR"] + root && !root.empty? ? File.join(root, "hive") : File.join(state_dir, "run") + end + + def bin_dir + ENV.fetch("HIVE_BIN_DIR") { File.join(home, ".local", "bin") } + end + + def config_file + File.join(config_dir, "config.yml") + end + + def receipt_file + ENV.fetch("HIVE_INSTALL_RECEIPT") { File.join(data_dir, "install-receipt.yml") } + end + + def releases_dir + File.join(data_dir, "releases") + end + + def release_dir(version = Hive::VERSION) + File.join(releases_dir, version) + end + + def resource_root + override = ENV["HIVE_RESOURCE_ROOT"] + return File.expand_path(override) if override && !override.empty? + + File.expand_path("../..", __dir__) + end + + def resource(*parts) + File.join(resource_root, *parts) + end + + def home + File.expand_path(ENV.fetch("HOME")) + end + + def xdg_root(name, fallback) + configured = ENV[name] + return File.expand_path(configured) if configured && !configured.empty? + + File.join(home, fallback) + end + private_class_method :xdg_root + end +end diff --git a/lib/hive/service.rb b/lib/hive/service.rb new file mode 100644 index 00000000..8fce3b7c --- /dev/null +++ b/lib/hive/service.rb @@ -0,0 +1,24 @@ +require "rbconfig" +require "hive/paths" +require "hive/command_name" +require "hive/services/launchd" +require "hive/services/systemd_user" + +module Hive + module Service + module_function + + def current(executable: nil, platform: nil) + executable ||= Hive::CommandName.stable_executable + platform ||= ENV["HIVE_SERVICE_PLATFORM"] || RbConfig::CONFIG["host_os"] + case platform + when /darwin/ + Hive::Services::Launchd.new(executable: executable) + when /linux/ + Hive::Services::SystemdUser.new(executable: executable) + else + raise Hive::ConfigError, "unsupported user service manager for #{platform}" + end + end + end +end diff --git a/lib/hive/services/launchd.rb b/lib/hive/services/launchd.rb new file mode 100644 index 00000000..bbf8320e --- /dev/null +++ b/lib/hive/services/launchd.rb @@ -0,0 +1,79 @@ +require "cgi" +require "fileutils" +require "open3" +require "hive/paths" + +module Hive + module Services + class Launchd + LABEL = "org.hive.daemon".freeze + + def initialize(executable:, home: Dir.home, runner: Open3.method(:capture3), uid: Process.uid) + @executable = File.expand_path(executable) + @home = home + @runner = runner + @uid = uid + end + + def definition_path + File.join(@home, "Library", "LaunchAgents", "#{LABEL}.plist") + end + + def register + FileUtils.mkdir_p(File.dirname(definition_path)) + content = File.read(Hive::Paths.resource("assets", "services", "launchd.plist")) + .gsub("@EXECUTABLE@", CGI.escapeHTML(@executable)) + .gsub("@STATE_DIR@", CGI.escapeHTML(Hive::Paths.state_dir)) + write_atomic(definition_path, content) + :registered_stopped + end + + def start + execute("launchctl", "bootstrap", domain, definition_path, allow_failure: true) + execute("launchctl", "kickstart", "#{domain}/#{LABEL}") + :running + end + + def stop + execute("launchctl", "bootout", "#{domain}/#{LABEL}", allow_failure: true) + :registered_stopped + end + + def status + _out, _err, result = @runner.call("launchctl", "print", "#{domain}/#{LABEL}") + result.success? ? :running : :registered_stopped + end + + def unregister + return :unregistered unless File.exist?(definition_path) || File.symlink?(definition_path) + + stop + FileUtils.rm_f(definition_path) + :unregistered + end + + private + + def domain + "gui/#{@uid}" + end + + def write_atomic(path, content) + temporary = "#{path}.tmp-#{Process.pid}" + File.write(temporary, content) + File.rename(temporary, path) + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def execute(*command, allow_failure: false) + _out, err, status = @runner.call(*command) + return if status.success? || allow_failure + + raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}" + rescue Errno::ENOENT + raise Hive::ConfigError, "launchd is unavailable" + end + end + end +end diff --git a/lib/hive/services/systemd_user.rb b/lib/hive/services/systemd_user.rb new file mode 100644 index 00000000..ed28daa0 --- /dev/null +++ b/lib/hive/services/systemd_user.rb @@ -0,0 +1,97 @@ +require "fileutils" +require "open3" +require "shellwords" +require "hive/paths" + +module Hive + module Services + class SystemdUser + UNIT = "hive.service".freeze + + def initialize(executable:, config_home: ENV["XDG_CONFIG_HOME"], runner: Open3.method(:capture3)) + @executable = File.expand_path(executable) + @config_home = config_home && !config_home.empty? ? config_home : File.join(Dir.home, ".config") + @runner = runner + end + + def definition_path + File.join(@config_home, "systemd", "user", UNIT) + end + + def register + previous = File.binread(definition_path) if File.file?(definition_path) && !File.symlink?(definition_path) + write_definition + execute("systemctl", "--user", "daemon-reload") + :registered_stopped + rescue Hive::Error + restore_definition(previous) + execute("systemctl", "--user", "daemon-reload", allow_failure: true) + raise + end + + def start + execute("systemctl", "--user", "enable", "--now", UNIT) + :running + end + + def stop + execute("systemctl", "--user", "disable", "--now", UNIT, allow_failure: true) + :registered_stopped + end + + def status + _out, _err, result = @runner.call("systemctl", "--user", "is-active", "--quiet", UNIT) + result.success? ? :running : :registered_stopped + end + + def unregister + return :unregistered unless File.exist?(definition_path) || File.symlink?(definition_path) + + stop + previous = File.binread(definition_path) if File.file?(definition_path) && !File.symlink?(definition_path) + FileUtils.rm_f(definition_path) + execute("systemctl", "--user", "daemon-reload") + :unregistered + rescue Hive::Error + restore_definition(previous) if defined?(previous) + execute("systemctl", "--user", "daemon-reload", allow_failure: true) + raise + end + + private + + def write_definition + FileUtils.mkdir_p(File.dirname(definition_path)) + content = File.read(Hive::Paths.resource("assets", "services", "hive.service")) + .gsub("@EXECUTABLE@", Shellwords.escape(@executable)) + .gsub("@STATE_DIR@", Shellwords.escape(Hive::Paths.state_dir)) + write_atomic(definition_path, content) + end + + def write_atomic(path, content) + temporary = "#{path}.tmp-#{Process.pid}" + File.write(temporary, content) + File.rename(temporary, path) + ensure + FileUtils.rm_f(temporary) if defined?(temporary) && temporary + end + + def restore_definition(previous) + if previous + write_atomic(definition_path, previous) + else + FileUtils.rm_f(definition_path) + end + end + + def execute(*command, allow_failure: false) + _out, err, status = @runner.call(*command) + return if status.success? || allow_failure + + raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}" + rescue Errno::ENOENT + raise Hive::ConfigError, "systemd user service manager is unavailable" + end + end + end +end diff --git a/lib/hive/version.rb b/lib/hive/version.rb new file mode 100644 index 00000000..a89e72fa --- /dev/null +++ b/lib/hive/version.rb @@ -0,0 +1,3 @@ +module Hive + VERSION = "0.1.0".freeze unless const_defined?(:VERSION) +end diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO new file mode 100644 index 00000000..48837e93 --- /dev/null +++ b/packaging/aur/.SRCINFO @@ -0,0 +1,14 @@ +pkgbase = hive-bin + pkgdesc = Agent-driven software delivery pipeline (prebuilt) + pkgver = 0.1.0 + pkgrel = 1 + url = https://github.com/example/hive + arch = x86_64 + license = MIT + depends = glibc + provides = hive + install = hive-bin.install + source_x86_64 = hive-0.1.0-linux-x86_64.tar.gz::https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz + sha256sums_x86_64 = 0000000000000000000000000000000000000000000000000000000000000000 + +pkgname = hive-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 00000000..36462710 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,33 @@ +# Maintainer: Hive release automation +# Generated by scripts/render-packages; checksum is replaced at publication. +pkgname=hive-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Agent-driven software delivery pipeline (prebuilt)' +arch=('x86_64') +url='https://github.com/example/hive' +license=('MIT') +depends=('glibc') +provides=('hive') +install=hive-bin.install +source_x86_64=('hive-'${pkgver}'-linux-x86_64.tar.gz::https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz') +sha256sums_x86_64=('0000000000000000000000000000000000000000000000000000000000000000') + +package() { + install -Dm755 "$srcdir/hive-$pkgver/bin/hv" "$pkgdir/usr/lib/hive/hv" + install -Dm644 "$srcdir/hive-$pkgver/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 /dev/stdin "$pkgdir/usr/share/hive/install-receipt.yml" </dev/null 2>&1; then + echo "Hive installed as hv; preserving existing hive command." + else + ln -s /usr/bin/hv /usr/bin/hive + sed -i 's/^command_name: hv$/command_name: hive/' /usr/share/hive/install-receipt.yml + fi + for tool in git bash claude gh jq; do + command -v "$tool" >/dev/null 2>&1 || + echo "Optional Hive dependency missing: $tool. Install it separately." + done + echo "Run hive init (or hv init) to register the user daemon; package installation does not start it." +} + +pre_upgrade() { + if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then + rm -f /usr/bin/hive + fi +} + +post_upgrade() { + post_install +} + +pre_remove() { + if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then + rm -f /usr/bin/hive + fi +} diff --git a/packaging/homebrew/Formula/hive.rb b/packaging/homebrew/Formula/hive.rb new file mode 100644 index 00000000..0887ac18 --- /dev/null +++ b/packaging/homebrew/Formula/hive.rb @@ -0,0 +1,63 @@ +# Generated by scripts/render-packages from release-manifest.json. +# The checked-in v0.1.0 fixture is replaced during release publication. +class Hive < Formula + desc "Agent-driven software delivery pipeline" + homepage "https://github.com/example/hive" + version "0.1.0" + url "https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-darwin-arm64.tar.gz" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + + def install + libexec.install Dir["*"] + path_hive = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| Pathname(directory)/"hive" } + .find(&:executable?) + prefix_hive = HOMEBREW_PREFIX/"bin/hive" + prefix_hv = HOMEBREW_PREFIX/"bin/hv" + candidates = [ path_hive, (prefix_hive if prefix_hive.exist? || prefix_hive.symlink?) ].compact + owned_prefix_hive = prefix_hive.symlink? && prefix_hv.exist? && + prefix_hive.realpath == prefix_hv.realpath + foreign_hive = candidates.any? do |candidate| + !owned_prefix_hive || !File.identical?(candidate, prefix_hive) + rescue Errno::ENOENT + true + end + selected_command = foreign_hive ? "hv" : "hive" + + receipt = share/"hive/install-receipt.yml" + receipt.dirname.mkpath + receipt.write <<~YAML + schema_version: 1 + channel: homebrew + package: example/hive/hive + command_name: #{selected_command} + executable: #{opt_bin}/hv + release_source: https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-darwin-arm64.tar.gz + managed_files: + - #{opt_bin}/hv + YAML + (bin/"hv").write <<~SH + #!/bin/sh + HIVE_INSTALL_RECEIPT="#{receipt}" exec "#{libexec}/bin/hv" "$@" + SH + chmod 0755, bin/"hv" + bin.install_symlink "hv" => "hive" if selected_command == "hive" + end + + def post_install + %w[git bash claude gh jq].each do |tool| + opoo "Optional Hive dependency missing: #{tool}. Install it separately." unless which(tool) + end + end + + service do + run [ opt_bin/"hv", "daemon", "start" ] + keep_alive false + log_path var/"log/hive.log" + error_log_path var/"log/hive.log" + end + + test do + assert_equal version.to_s, shell_output("#{bin}/hv --version").strip + end +end diff --git a/packaging/receipt-template.yml b/packaging/receipt-template.yml new file mode 100644 index 00000000..fc93ea27 --- /dev/null +++ b/packaging/receipt-template.yml @@ -0,0 +1,8 @@ +schema_version: 1 +channel: CHANNEL +package: PACKAGE +command_name: COMMAND_NAME +executable: EXECUTABLE +release_source: RELEASE_SOURCE +managed_files: + - EXECUTABLE diff --git a/packaging/release-manifest.schema.json b/packaging/release-manifest.schema.json new file mode 100644 index 00000000..b4d5d6ef --- /dev/null +++ b/packaging/release-manifest.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hive.example/schemas/release-manifest.v1.json", + "type": "object", + "required": ["schema_version", "version", "tag", "repository", "ruby_version", "installer_compatibility", "targets", "shell_assets"], + "properties": { + "schema_version": { "const": 1 }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "repository": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "ruby_version": { "type": "string" }, + "installer_compatibility": { "const": 1 }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["target", "asset", "url", "sha256", "size", "minimum_os"], + "properties": { + "target": { "enum": ["darwin-arm64", "linux-x86_64"] }, + "asset": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "size": { "type": "integer", "minimum": 1 }, + "minimum_os": { "type": "string" } + }, + "additionalProperties": false + } + }, + "shell_assets": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "required": ["asset", "url", "sha256", "size"], + "properties": { + "asset": { "enum": ["install.sh", "uninstall-shell.sh"] }, + "url": { "type": "string", "format": "uri" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "size": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packaging/tebako/README.md b/packaging/tebako/README.md new file mode 100644 index 00000000..33b61e9e --- /dev/null +++ b/packaging/tebako/README.md @@ -0,0 +1,12 @@ +# Tebako builder adapter + +Hive's primary builder uses Tebako's single-executable `press` contract: + +```sh +tebako press --root= --entry-point=bin/hive \ + --output=/hive- --Ruby=3.4.1 +``` + +`scripts/build-release` is the replaceable boundary. A replacement builder may +be selected with `HIVE_PACKAGER`, but it must create the same executable path +and pass the packaged-binary and release-manifest contracts. diff --git a/packaging/tebako/versions.env b/packaging/tebako/versions.env new file mode 100644 index 00000000..e8d67a8d --- /dev/null +++ b/packaging/tebako/versions.env @@ -0,0 +1,2 @@ +TEBAKO_VERSION=0.12.0 +RUBY_VERSION=3.4.1 diff --git a/scripts/build-release b/scripts/build-release new file mode 100755 index 00000000..399b9372 --- /dev/null +++ b/scripts/build-release @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="" +target="" +output_dir="${root}/build" + +while (($#)); do + case "$1" in + --version) version="${2:-}"; shift 2 ;; + --target) target="${2:-}"; shift 2 ;; + --output-dir) output_dir="${2:-}"; shift 2 ;; + *) echo "build-release: unknown argument: $1" >&2; exit 64 ;; + esac +done + +if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "build-release: --version must be strict semver (without v)" >&2 + exit 64 +fi + +if [[ -z "${target}" ]]; then + case "$(uname -s)-$(uname -m)" in + Darwin-arm64) target="darwin-arm64" ;; + Linux-x86_64) target="linux-x86_64" ;; + *) echo "build-release: unsupported build host" >&2; exit 64 ;; + esac +fi + +case "${target}" in + darwin-arm64|linux-x86_64) ;; + *) echo "build-release: unsupported target ${target}" >&2; exit 64 ;; +esac + +source "${root}/packaging/tebako/versions.env" +packager="${HIVE_PACKAGER:-tebako}" +command -v "${packager}" >/dev/null 2>&1 || { + echo "build-release: ${packager} is unavailable" >&2 + exit 69 +} + +mkdir -p "${output_dir}" +output="${output_dir}/hive-${target}" +"${packager}" press \ + "--root=${root}" \ + "--entry-point=bin/hive" \ + "--output=${output}" \ + "--Ruby=${RUBY_VERSION}" +chmod 0755 "${output}" +"${output}" --version | grep -Fx "${version}" >/dev/null +echo "${output}" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..db5a8b63 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="${HIVE_RELEASE_REPOSITORY:-ivankuznetsov/hive}" +requested_version="" +update=false +uninstall=false +assume_yes=false +destination="${HIVE_BIN_DIR:-${HOME}/.local/bin}" +base_url="${HIVE_INSTALL_BASE_URL:-https://github.com/${repository}/releases/download}" + +usage() { + echo "usage: install.sh [--version X.Y.Z] [--update] [--uninstall] [--yes] [--dest-dir PATH]" +} + +while (($#)); do + case "$1" in + --version) requested_version="${2:-}"; shift 2 ;; + --update) update=true; shift ;; + --uninstall) uninstall=true; shift ;; + --yes) assume_yes=true; shift ;; + --dest-dir) destination="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "hive installer: unknown argument $1" >&2; usage >&2; exit 64 ;; + esac +done + +data_home="${XDG_DATA_HOME:-${HOME}/.local/share}/hive" +receipt="${data_home}/install-receipt.yml" +uninstaller="${data_home}/uninstall-shell.sh" +installed_installer="${data_home}/install.sh" + +temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-install.XXXXXX")" +trap 'rm -rf "${temporary}"' EXIT + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "hive installer: sha256sum or shasum is required" >&2 + exit 69 + fi +} + +receipt_value() { + [[ -f "${receipt}" ]] || return 0 + sed -n "s/^$1: //p" "${receipt}" | head -n 1 +} + +receipt_manages() { + [[ -f "${receipt}" ]] && grep -Fqx " - $1" "${receipt}" +} + +verify_installed_helper() { + path_key="$1" + digest_key="$2" + expected_name="$3" + path="$(receipt_value "${path_key}")" + expected="$(receipt_value "${digest_key}")" + if [[ "${path}" != "${data_home}/${expected_name}" || ! "${expected}" =~ ^[a-f0-9]{64}$ || + ! -f "${path}" || -L "${path}" || "$(sha256_file "${path}")" != "${expected}" ]]; then + echo "hive installer: ${expected_name} failed receipt ownership verification; reinstall Hive" >&2 + exit 78 + fi + if ! receipt_manages "${path}"; then + echo "hive installer: ${expected_name} failed receipt ownership verification; reinstall Hive" >&2 + exit 78 + fi +} + +if ${uninstall}; then + [[ "$(receipt_value schema_version)" == "1" && "$(receipt_value channel)" == "shell" ]] || { + echo "hive installer: valid shell uninstall receipt is missing" >&2 + exit 78 + } + verify_installed_helper uninstaller uninstaller_sha256 uninstall-shell.sh + if ${assume_yes}; then + exec "${uninstaller}" --yes + else + exec "${uninstaller}" + fi +fi + +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) target="darwin-arm64" ;; + Linux-x86_64|Linux-amd64) target="linux-x86_64" ;; + *) + echo "hive installer: unsupported OS/architecture $(uname -s)/$(uname -m)" >&2 + exit 64 + ;; +esac + +download() { + source_url="$1" + output="$2" + case "${source_url}" in + file://*) cp "${source_url#file://}" "${output}" ;; + *) + if command -v curl >/dev/null 2>&1; then + curl --fail --location --silent --show-error \ + --connect-timeout 10 --max-time 120 \ + --retry 3 --retry-delay 1 --retry-all-errors \ + "${source_url}" --output "${output}" + elif command -v wget >/dev/null 2>&1; then + wget -q --timeout=30 --tries=3 "${source_url}" -O "${output}" + else + echo "hive installer: curl or wget is required" >&2 + exit 69 + fi + ;; + esac +} + +if [[ -n "${requested_version}" ]]; then + [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "hive installer: version must be strict semver" >&2 + exit 64 + } + release_root="${base_url}/v${requested_version}" +else + if [[ -n "${HIVE_INSTALL_BASE_URL:-}" ]]; then + release_root="${base_url}/latest" + else + release_root="https://github.com/${repository}/releases/latest/download" + fi +fi + +manifest="${temporary}/release-manifest.json" +download "${release_root}/release-manifest.json" "${manifest}" +version="${requested_version}" +if [[ -z "${version}" ]]; then + version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "${manifest}" | head -n 1)" +fi +[[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "hive installer: manifest contains an invalid version" >&2 + exit 65 +} + +asset="hive-${version}-${target}.tar.gz" +record="$(awk -v wanted="${target}" ' + /"target":/ { selected = index($0, "\"" wanted "\"") > 0 } + selected { print } + selected && /"minimum_os":/ { exit } +' "${manifest}")" +manifest_asset="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"asset": "\([^"]*\)",*$/\1/p')" +expected_sha="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"sha256": "\([a-f0-9]*\)",*$/\1/p')" +expected_size="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"size": \([0-9]*\),*$/\1/p')" +if [[ "${manifest_asset}" != "${asset}" || ! "${expected_sha}" =~ ^[a-f0-9]{64}$ || + ! "${expected_size}" =~ ^[1-9][0-9]*$ ]]; then + echo "hive installer: target is absent or invalid in the checksum manifest" >&2 + exit 65 +fi + +archive="${temporary}/${asset}" +download "${release_root}/${asset}" "${archive}" +actual_sha="$(sha256_file "${archive}")" +actual_size="$(wc -c < "${archive}" | tr -d ' ')" +if [[ "${actual_sha}" != "${expected_sha}" || "${actual_size}" != "${expected_size}" ]]; then + echo "hive installer: SHA-256 verification failed" >&2 + exit 65 +fi + +tar -xzf "${archive}" -C "${temporary}" +candidate="${temporary}/hive-${version}/bin/hv" +[[ -x "${candidate}" ]] || { echo "hive installer: archive has no executable hv" >&2; exit 65; } +[[ "$("${candidate}" --version)" == "${version}" ]] || { + echo "hive installer: packaged executable version check failed" >&2 + exit 65 +} + +manifest_shell_record() { + wanted="$1" + awk -v wanted="${wanted}" ' + $0 ~ "\"asset\": \"" wanted "\"" { selected = 1 } + selected { print } + selected && /^[[:space:]]*}/ { exit } + ' "${manifest}" +} + +for helper in install.sh uninstall-shell.sh; do + helper_record="$(manifest_shell_record "${helper}")" + helper_sha="$(printf '%s\n' "${helper_record}" | sed -n 's/^[[:space:]]*"sha256": "\([a-f0-9]*\)",*$/\1/p')" + helper_size="$(printf '%s\n' "${helper_record}" | sed -n 's/^[[:space:]]*"size": \([0-9]*\),*$/\1/p')" + if [[ ! "${helper_sha}" =~ ^[a-f0-9]{64}$ || ! "${helper_size}" =~ ^[1-9][0-9]*$ ]]; then + echo "hive installer: ${helper} is absent or invalid in the checksum manifest" >&2 + exit 65 + fi + download "${release_root}/${helper}" "${temporary}/${helper}" + if [[ "$(sha256_file "${temporary}/${helper}")" != "${helper_sha}" || + "$(wc -c < "${temporary}/${helper}" | tr -d ' ')" != "${helper_size}" ]]; then + echo "hive installer: SHA-256 verification failed for ${helper}" >&2 + exit 65 + fi + if [[ "${helper}" == "install.sh" ]]; then + installer_sha="${helper_sha}" + else + uninstaller_sha="${helper_sha}" + fi +done + +mkdir -p "${destination}" "${data_home}" + +owns_hv=false +owns_alias=false +owns_installer=false +owns_uninstaller=false +owns_receipt=false +if [[ -f "${receipt}" && "$(receipt_value schema_version)" == "1" && + "$(receipt_value channel)" == "shell" && + "$(receipt_value executable)" == "${destination}/hv" ]]; then + old_hv_sha="$(receipt_value executable_sha256)" + if receipt_manages "${destination}/hv" && [[ "${old_hv_sha}" =~ ^[a-f0-9]{64}$ && + -f "${destination}/hv" && ! -L "${destination}/hv" && + "$(sha256_file "${destination}/hv")" == "${old_hv_sha}" ]]; then + owns_hv=true + fi + if receipt_manages "${destination}/hive" && + [[ -L "${destination}/hive" && "$(readlink "${destination}/hive")" == "hv" ]]; then + owns_alias=true + fi + old_installer_sha="$(receipt_value installer_sha256)" + if receipt_manages "${installed_installer}" && [[ "$(receipt_value installer)" == "${installed_installer}" && + "${old_installer_sha}" =~ ^[a-f0-9]{64}$ && -f "${installed_installer}" && + ! -L "${installed_installer}" && "$(sha256_file "${installed_installer}")" == "${old_installer_sha}" ]]; then + owns_installer=true + fi + old_uninstaller_sha="$(receipt_value uninstaller_sha256)" + if receipt_manages "${uninstaller}" && [[ "$(receipt_value uninstaller)" == "${uninstaller}" && + "${old_uninstaller_sha}" =~ ^[a-f0-9]{64}$ && -f "${uninstaller}" && + ! -L "${uninstaller}" && "$(sha256_file "${uninstaller}")" == "${old_uninstaller_sha}" ]]; then + owns_uninstaller=true + fi + receipt_manages "${receipt}" && owns_receipt=true +fi + +if [[ -e "${destination}/hv" || -L "${destination}/hv" ]] && ! ${owns_hv}; then + echo "hive installer: refusing to overwrite foreign ${destination}/hv" >&2 + exit 73 +fi +for owned_pair in \ + "${installed_installer}:${owns_installer}" \ + "${uninstaller}:${owns_uninstaller}" \ + "${receipt}:${owns_receipt}"; do + owned_path="${owned_pair%:*}" + owned_value="${owned_pair##*:}" + if [[ -e "${owned_path}" || -L "${owned_path}" ]] && ! ${owned_value}; then + echo "hive installer: refusing to overwrite unowned ${owned_path}" >&2 + exit 73 + fi +done + +selected_command="hive" +install_alias=true +if [[ -e "${destination}/hive" || -L "${destination}/hive" ]]; then + if ! ${owns_alias}; then + selected_command="hv" + install_alias=false + fi +else + existing_hive="$(command -v hive 2>/dev/null || true)" + if [[ -n "${existing_hive}" ]]; then + selected_command="hv" + install_alias=false + fi +fi + +staged_hv="${destination}/.hv.new-$$" +staged_installer="${data_home}/.install.sh.new-$$" +staged_uninstaller="${data_home}/.uninstall-shell.sh.new-$$" +staged_receipt="${data_home}/.install-receipt.yml.new-$$" +staged_alias="${destination}/.hive.new-$$" +cp "${candidate}" "${staged_hv}" +cp "${temporary}/install.sh" "${staged_installer}" +cp "${temporary}/uninstall-shell.sh" "${staged_uninstaller}" +chmod 0755 "${staged_hv}" "${staged_installer}" "${staged_uninstaller}" +executable_sha="$(sha256_file "${staged_hv}")" + +cat > "${staged_receipt}" <> "${staged_receipt}" + ln -s hv "${staged_alias}" +fi +chmod 0600 "${staged_receipt}" + +[[ "$("${staged_hv}" --version)" == "${version}" ]] || { + echo "hive installer: staged executable version check failed" >&2 + exit 65 +} + +targets=( "${installed_installer}" "${uninstaller}" "${receipt}" ) +staged=( "${staged_installer}" "${staged_uninstaller}" "${staged_receipt}" ) +actions=( replace replace replace ) +if ${install_alias}; then + targets+=( "${destination}/hive" ) + staged+=( "${staged_alias}" ) + actions+=( replace ) +elif ${owns_alias}; then + targets+=( "${destination}/hive" ) + staged+=( "" ) + actions+=( remove ) +fi +targets+=( "${destination}/hv" ) +staged+=( "${staged_hv}" ) +actions+=( replace ) + +existed=() +for index in "${!targets[@]}"; do + target_path="${targets[$index]}" + backup_path="${target_path}.hive-backup-$$" + rm -f "${backup_path}" + if [[ -e "${target_path}" || -L "${target_path}" ]]; then + cp -a "${target_path}" "${backup_path}" + existed[$index]=true + else + existed[$index]=false + fi +done + +commit_failed=false +set +e +for index in "${!targets[@]}"; do + if [[ "${actions[$index]}" == "replace" ]]; then + mv -f "${staged[$index]}" "${targets[$index]}" || { commit_failed=true; break; } + else + rm -f "${targets[$index]}" || { commit_failed=true; break; } + fi +done +set -e + +if ${commit_failed}; then + set +e + for index in "${!targets[@]}"; do + rm -f "${targets[$index]}" + if ${existed[$index]}; then + mv -f "${targets[$index]}.hive-backup-$$" "${targets[$index]}" + fi + done + set -e + echo "hive installer: install transaction failed; previous installation restored" >&2 + exit 74 +fi + +for target_path in "${targets[@]}"; do + rm -f "${target_path}.hive-backup-$$" +done + +echo "hive ${version} installed as ${selected_command} in ${destination}" +case ":${PATH}:" in + *":${destination}:"*) ;; + *) echo "Add ${destination} to PATH before invoking ${selected_command}." ;; +esac + +for tool in git bash claude gh jq; do + command -v "${tool}" >/dev/null 2>&1 || echo "Optional dependency missing: ${tool} (install it separately)." >&2 +done diff --git a/scripts/package-release b/scripts/package-release new file mode 100755 index 00000000..02e13c9b --- /dev/null +++ b/scripts/package-release @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="" +build_dir="${root}/build" +dist_dir="${root}/dist" + +while (($#)); do + case "$1" in + --version) version="${2:-}"; shift 2 ;; + --build-dir) build_dir="${2:-}"; shift 2 ;; + --dist-dir) dist_dir="${2:-}"; shift 2 ;; + *) echo "package-release: unknown argument: $1" >&2; exit 64 ;; + esac +done + +if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "package-release: --version must be strict semver" >&2 + exit 64 +fi + +repository="${HIVE_RELEASE_REPOSITORY:-}" +if [[ ! "${repository}" =~ ^[^/]+/[^/]+$ ]]; then + echo "package-release: HIVE_RELEASE_REPOSITORY must be org/repository" >&2 + exit 64 +fi + +source "${root}/packaging/tebako/versions.env" +mkdir -p "${dist_dir}" +temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-package.XXXXXX")" +trap 'rm -rf "${temporary}"' EXIT +records="${temporary}/targets.tsv" +: > "${records}" + +for target in darwin-arm64 linux-x86_64; do + binary="${build_dir}/hive-${target}" + [[ -f "${binary}" ]] || continue + case "${target}" in + darwin-arm64) minimum_os="macOS 13" ;; + linux-x86_64) minimum_os="Ubuntu 22.04 / Arch" ;; + *) echo "package-release: unexpected target ${target}" >&2; exit 64 ;; + esac + + stage="${temporary}/${target}/hive-${version}" + mkdir -p "${stage}/bin" "${stage}/share/hive" + cp "${binary}" "${stage}/bin/hv" + chmod 0755 "${stage}/bin/hv" + ln -s hv "${stage}/bin/hive" + cp "${root}/LICENSE" "${stage}/LICENSE" + cp "${root}/packaging/receipt-template.yml" "${stage}/share/hive/receipt-template.yml" + cp -R "${root}/assets/shared/." "${stage}/share/hive/" + cp -R "${root}/assets/project" "${stage}/share/hive/project" + + asset="hive-${version}-${target}.tar.gz" + tar -C "${temporary}/${target}" -czf "${dist_dir}/${asset}" "hive-${version}" + sha="$(sha256sum "${dist_dir}/${asset}" | awk '{print $1}')" + size="$(wc -c < "${dist_dir}/${asset}" | tr -d ' ')" + url="https://github.com/${repository}/releases/download/v${version}/${asset}" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "${target}" "${asset}" "${url}" "${sha}" "${size}" "${minimum_os}" >> "${records}" +done + +[[ -s "${records}" ]] || { echo "package-release: no target binaries found" >&2; exit 66; } +cp "${root}/scripts/install.sh" "${root}/scripts/uninstall-shell.sh" "${dist_dir}/" +chmod 0755 "${dist_dir}/install.sh" "${dist_dir}/uninstall-shell.sh" + +ruby -rjson -e ' + require "digest" + version, repository, ruby_version, records, dist, output = ARGV + targets = File.readlines(records, chomp: true).map do |line| + target, asset, url, sha, size, minimum_os = line.split("\t", 6) + { "target" => target, "asset" => asset, "url" => url, "sha256" => sha, + "size" => Integer(size), "minimum_os" => minimum_os } + end + manifest = { + "schema_version" => 1, "version" => version, "tag" => "v#{version}", + "repository" => repository, "ruby_version" => ruby_version, + "installer_compatibility" => 1, "targets" => targets.sort_by { |row| row["target"] }, + "shell_assets" => %w[install.sh uninstall-shell.sh].map do |asset| + path = File.join(dist, asset) + { + "asset" => asset, + "url" => "https://github.com/#{repository}/releases/download/v#{version}/#{asset}", + "sha256" => Digest::SHA256.file(path).hexdigest, + "size" => File.size(path) + } + end + } + File.write(output, JSON.pretty_generate(manifest) + "\n") +' "${version}" "${repository}" "${RUBY_VERSION}" "${records}" "${dist_dir}" "${dist_dir}/release-manifest.json" + +( + cd "${dist_dir}" + sha256sum hive-"${version}"-*.tar.gz install.sh uninstall-shell.sh release-manifest.json > SHA256SUMS +) diff --git a/scripts/publish-packages b/scripts/publish-packages new file mode 100755 index 00000000..f36dd7d1 --- /dev/null +++ b/scripts/publish-packages @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest="" +while (($#)); do + case "$1" in + --manifest) manifest="${2:-}"; shift 2 ;; + *) echo "publish-packages: unknown argument $1" >&2; exit 64 ;; + esac +done +[[ -f "${manifest}" ]] || { echo "publish-packages: manifest is required" >&2; exit 64; } +: "${HOMEBREW_TAP_DIR:?set HOMEBREW_TAP_DIR to a checked-out tap repository}" +: "${AUR_REPO_DIR:?set AUR_REPO_DIR to a checked-out hive-bin AUR repository}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-packages.XXXXXX")" +trap 'rm -rf "${temporary}"' EXIT +"${root}/scripts/verify-release" --manifest "${manifest}" --remote +"${root}/scripts/render-packages" --manifest "${manifest}" --output-dir "${temporary}" + +mkdir -p "${HOMEBREW_TAP_DIR}/Formula" +cp "${temporary}/homebrew/Formula/hive.rb" "${HOMEBREW_TAP_DIR}/Formula/hive.rb" +cp "${temporary}/aur/PKGBUILD" "${temporary}/aur/.SRCINFO" \ + "${temporary}/aur/hive-bin.install" "${AUR_REPO_DIR}/" +echo "Package metadata rendered after release asset verification; commit and push each downstream repository." diff --git a/scripts/render-packages b/scripts/render-packages new file mode 100755 index 00000000..950c20e1 --- /dev/null +++ b/scripts/render-packages @@ -0,0 +1,181 @@ +#!/usr/bin/env ruby + +require "fileutils" +require "json" +require "optparse" + +options = { output_dir: File.expand_path("../packaging", __dir__) } +OptionParser.new do |parser| + parser.on("--manifest PATH") { |value| options[:manifest] = value } + parser.on("--output-dir PATH") { |value| options[:output_dir] = value } +end.parse! +abort "render-packages: --manifest is required" unless options[:manifest] + +manifest = JSON.parse(File.read(options[:manifest])) +version = manifest.fetch("version") +repository = manifest.fetch("repository") +darwin = manifest.fetch("targets").find { |row| row["target"] == "darwin-arm64" } or abort "missing darwin-arm64" +linux = manifest.fetch("targets").find { |row| row["target"] == "linux-x86_64" } or abort "missing linux-x86_64" + +formula = <<~RUBY + class Hive < Formula + desc "Agent-driven software delivery pipeline" + homepage "https://github.com/#{repository}" + version "#{version}" + url "#{darwin.fetch('url')}" + sha256 "#{darwin.fetch('sha256')}" + + def install + libexec.install Dir["*"] + path_hive = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR) + .map { |directory| Pathname(directory)/"hive" } + .find(&:executable?) + prefix_hive = HOMEBREW_PREFIX/"bin/hive" + prefix_hv = HOMEBREW_PREFIX/"bin/hv" + candidates = [ path_hive, (prefix_hive if prefix_hive.exist? || prefix_hive.symlink?) ].compact + owned_prefix_hive = prefix_hive.symlink? && prefix_hv.exist? && + prefix_hive.realpath == prefix_hv.realpath + foreign_hive = candidates.any? do |candidate| + !owned_prefix_hive || !File.identical?(candidate, prefix_hive) + rescue Errno::ENOENT + true + end + selected_command = foreign_hive ? "hv" : "hive" + + receipt = share/"hive/install-receipt.yml" + receipt.dirname.mkpath + receipt.write <<~YAML + schema_version: 1 + channel: homebrew + package: #{repository}/hive + command_name: \#{selected_command} + executable: \#{opt_bin}/hv + release_source: #{darwin.fetch('url')} + managed_files: + - \#{opt_bin}/hv + YAML + (bin/"hv").write <<~SH + #!/bin/sh + HIVE_INSTALL_RECEIPT="\#{receipt}" exec "\#{libexec}/bin/hv" "$@" + SH + chmod 0755, bin/"hv" + bin.install_symlink "hv" => "hive" if selected_command == "hive" + end + + def post_install + %w[git bash claude gh jq].each do |tool| + opoo "Optional Hive dependency missing: \#{tool}. Install it separately." unless which(tool) + end + end + + service do + run [ opt_bin/"hv", "daemon", "start" ] + keep_alive false + log_path var/"log/hive.log" + error_log_path var/"log/hive.log" + end + + test do + assert_equal version.to_s, shell_output("\#{bin}/hv --version").strip + end + end +RUBY + +pkgbuild = <<~BASH + # Maintainer: Hive release automation + pkgname=hive-bin + pkgver=#{version} + pkgrel=1 + pkgdesc='Agent-driven software delivery pipeline (prebuilt)' + arch=('x86_64') + url='https://github.com/#{repository}' + license=('MIT') + depends=('glibc') + provides=('hive') + install=hive-bin.install + source_x86_64=('hive-'${pkgver}'-linux-x86_64.tar.gz::#{linux.fetch('url')}') + sha256sums_x86_64=('#{linux.fetch('sha256')}') + + package() { + install -Dm755 "$srcdir/hive-$pkgver/bin/hv" "$pkgdir/usr/lib/hive/hv" + install -Dm644 "$srcdir/hive-$pkgver/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 /dev/stdin "$pkgdir/usr/share/hive/install-receipt.yml" </dev/null 2>&1; then + echo "Hive installed as hv; preserving existing hive command." + else + ln -s /usr/bin/hv /usr/bin/hive + sed -i 's/^command_name: hv$/command_name: hive/' /usr/share/hive/install-receipt.yml + fi + for tool in git bash claude gh jq; do + command -v "$tool" >/dev/null 2>&1 || + echo "Optional Hive dependency missing: $tool. Install it separately." + done + echo "Run hive init (or hv init) to register the user daemon; package installation does not start it." + } + + pre_upgrade() { + if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then + rm -f /usr/bin/hive + fi + } + + post_upgrade() { + post_install + } + + pre_remove() { + if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then + rm -f /usr/bin/hive + fi + } +BASH + +files = { + "homebrew/Formula/hive.rb" => formula, + "aur/PKGBUILD" => pkgbuild, + "aur/.SRCINFO" => srcinfo, + "aur/hive-bin.install" => install_hook +} +files.each do |relative, content| + path = File.join(options[:output_dir], relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) +end diff --git a/scripts/uninstall-shell.sh b/scripts/uninstall-shell.sh new file mode 100755 index 00000000..e7d129fd --- /dev/null +++ b/scripts/uninstall-shell.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +assume_yes=false +while (($#)); do + case "$1" in + --yes) assume_yes=true; shift ;; + *) echo "hive shell uninstaller: unknown argument $1" >&2; exit 64 ;; + esac +done + +data_home="${XDG_DATA_HOME:-${HOME}/.local/share}/hive" +receipt="${data_home}/install-receipt.yml" +[[ -f "${receipt}" ]] || { echo "hive shell uninstaller: receipt missing" >&2; exit 78; } +schema_version="$(sed -n 's/^schema_version: //p' "${receipt}")" +channel="$(sed -n 's/^channel: //p' "${receipt}")" +[[ "${schema_version}" == "1" && "${channel}" == "shell" ]] || { + echo "hive shell uninstaller: receipt is not a supported shell receipt" >&2 + exit 78 +} +executable="$(sed -n 's/^executable: //p' "${receipt}")" +expected_executable_sha="$(sed -n 's/^executable_sha256: //p' "${receipt}")" +expected_uninstaller_sha="$(sed -n 's/^uninstaller_sha256: //p' "${receipt}")" +receipt_uninstaller="$(sed -n 's/^uninstaller: //p' "${receipt}")" + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "hive shell uninstaller: sha256sum or shasum is required" >&2 + exit 69 + fi +} + +managed() { + grep -Fqx " - $1" "${receipt}" +} + +if [[ "${receipt_uninstaller}" != "$0" || -L "$0" || + ! "${expected_uninstaller_sha}" =~ ^[a-f0-9]{64}$ || + "$(sha256_file "$0")" != "${expected_uninstaller_sha}" ]] || ! managed "$0"; then + echo "hive shell uninstaller: uninstaller ownership verification failed" >&2 + exit 78 +fi +if [[ ! "${expected_executable_sha}" =~ ^[a-f0-9]{64}$ || ! -f "${executable}" || + -L "${executable}" || "$(sha256_file "${executable}")" != "${expected_executable_sha}" ]] || + ! managed "${executable}"; then + echo "hive shell uninstaller: executable ownership verification failed" >&2 + exit 78 +fi + +if ! ${assume_yes}; then + printf 'Remove the shell-managed Hive executable? [y/N] ' + read -r answer + [[ "${answer}" =~ ^[Yy]$ ]] || { echo "hive shell uninstaller: cancelled"; exit 0; } +fi + +if [[ -n "${executable}" && -f "${executable}" && ! -L "${executable}" ]]; then + alias_path="$(dirname "${executable}")/hive" + if managed "${alias_path}" && [[ -L "${alias_path}" && "$(readlink "${alias_path}")" == "hv" ]]; then + rm -f "${alias_path}" + fi + rm -f "${executable}" +fi +rm -f "${receipt}" "${data_home}/install.sh" +echo "Hive core removed; project work, XDG config/state, outputs, and agent skills were preserved." +rm -f -- "$0" diff --git a/scripts/verify-release b/scripts/verify-release new file mode 100755 index 00000000..98a232d4 --- /dev/null +++ b/scripts/verify-release @@ -0,0 +1,85 @@ +#!/usr/bin/env ruby + +require "digest" +require "json" +require "open3" +require "optparse" +require "tmpdir" + +options = {} +OptionParser.new do |parser| + parser.on("--manifest PATH") { |value| options[:manifest] = value } + parser.on("--dist-dir PATH") { |value| options[:dist_dir] = value } + parser.on("--remote") { options[:remote] = true } +end.parse! +abort "verify-release: --manifest is required" unless options[:manifest] + +manifest_path = File.expand_path(options[:manifest]) +dist = File.expand_path(options[:dist_dir] || File.dirname(manifest_path)) +manifest = JSON.parse(File.read(manifest_path)) +version = manifest.fetch("version") +abort "verify-release: invalid semver" unless version.match?(/\A\d+\.\d+\.\d+\z/) +abort "verify-release: tag/version mismatch" unless manifest["tag"] == "v#{version}" +targets = manifest.fetch("targets") +shell_assets = manifest.fetch("shell_assets") +expected_targets = %w[darwin-arm64 linux-x86_64] +abort "verify-release: tier-1 target set mismatch" unless targets.map { |row| row["target"] }.sort == expected_targets +expected_shell_assets = %w[install.sh uninstall-shell.sh] +unless shell_assets.map { |row| row["asset"] }.sort == expected_shell_assets + abort "verify-release: shell asset set mismatch" +end + +records = targets + shell_assets +records.each do |row| + archive = File.join(dist, row.fetch("asset")) + abort "verify-release: missing #{archive}" unless File.file?(archive) + abort "verify-release: size mismatch for #{archive}" unless File.size(archive) == row.fetch("size") + abort "verify-release: digest mismatch for #{archive}" unless Digest::SHA256.file(archive).hexdigest == row.fetch("sha256") +end + +targets.each do |row| + archive = File.join(dist, row.fetch("asset")) + listing, error, status = Open3.capture3("tar", "-tzf", archive) + abort "verify-release: unreadable archive #{archive}: #{error}" unless status.success? + %W[hive-#{version}/bin/hv hive-#{version}/bin/hive hive-#{version}/LICENSE].each do |entry| + abort "verify-release: #{archive} missing #{entry}" unless listing.lines.map(&:chomp).include?(entry) + end +end + +if options[:remote] + Dir.mktmpdir("hive-remote-verify") do |directory| + records.each do |row| + downloaded = File.join(directory, row.fetch("asset")) + _out, error, status = Open3.capture3( + "curl", "--fail", "--silent", "--show-error", "--location", + "--connect-timeout", "10", "--max-time", "120", + "--retry", "3", "--retry-delay", "1", "--retry-all-errors", + "--output", downloaded, row.fetch("url") + ) + abort "verify-release: remote asset unavailable: #{error}" unless status.success? + unless File.size(downloaded) == row.fetch("size") + abort "verify-release: remote size mismatch for #{row.fetch('asset')}" + end + unless Digest::SHA256.file(downloaded).hexdigest == row.fetch("sha256") + abort "verify-release: remote digest mismatch for #{row.fetch('asset')}" + end + end + end +end + +Dir.mktmpdir("hive-render-verify") do |directory| + _out, error, status = Open3.capture3( + File.expand_path("render-packages", __dir__), + "--manifest", manifest_path, + "--output-dir", directory + ) + abort "verify-release: package rendering failed: #{error}" unless status.success? + formula = File.read(File.join(directory, "homebrew/Formula/hive.rb")) + pkgbuild = File.read(File.join(directory, "aur/PKGBUILD")) + darwin = targets.find { |row| row["target"] == "darwin-arm64" } + linux = targets.find { |row| row["target"] == "linux-x86_64" } + abort "verify-release: Homebrew digest drift" unless formula.include?(darwin.fetch("sha256")) + abort "verify-release: AUR digest drift" unless pkgbuild.include?(linux.fetch("sha256")) +end + +puts "verified Hive v#{version}: #{expected_targets.join(', ')}" diff --git a/test/acceptance/arch_aur_test.rb b/test/acceptance/arch_aur_test.rb new file mode 100644 index 00000000..be03826f --- /dev/null +++ b/test/acceptance/arch_aur_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class ArchAurAcceptanceTest < Minitest::Test + def test_aur_package_is_prebuilt_and_does_not_start_daemon + root = File.expand_path("../..", __dir__) + pkgbuild = File.read(File.join(root, "packaging/aur/PKGBUILD")) + hook = File.read(File.join(root, "packaging/aur/hive-bin.install")) + assert_includes pkgbuild, "pkgname=hive-bin" + refute_match(/ruby/, pkgbuild) + refute_match(/systemctl.*enable|systemctl.*start/, hook) + end +end diff --git a/test/acceptance/macos_homebrew_test.rb b/test/acceptance/macos_homebrew_test.rb new file mode 100644 index 00000000..9dca5b68 --- /dev/null +++ b/test/acceptance/macos_homebrew_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class MacosHomebrewAcceptanceTest < Minitest::Test + def test_formula_is_manifest_generated_and_does_not_depend_on_ruby + formula = File.read(File.expand_path("../../packaging/homebrew/Formula/hive.rb", __dir__)) + assert_includes formula, "Generated by scripts/render-packages" + refute_match(/depends_on\s+[\"']ruby/, formula) + refute_match(/brew services start/, formula) + end +end diff --git a/test/acceptance/prompt_installer_test.rb b/test/acceptance/prompt_installer_test.rb new file mode 100644 index 00000000..7922f5a5 --- /dev/null +++ b/test/acceptance/prompt_installer_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class PromptInstallerAcceptanceTest < Minitest::Test + def test_one_prompt_names_all_agents_and_three_consent_boundaries + prompt = File.read(File.expand_path("../../docs/install-with-agent.md", __dir__)) + %w[Claude Codex Pi Homebrew hive-bin].each { |value| assert_includes prompt, value } + assert_operator prompt.scan(/ask .*approval|Ask /i).size, :>=, 3 + end +end diff --git a/test/acceptance/release_qualification_test.rb b/test/acceptance/release_qualification_test.rb new file mode 100644 index 00000000..ef8984a7 --- /dev/null +++ b/test/acceptance/release_qualification_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class ReleaseQualificationTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + + def test_release_workflow_is_two_phase_and_distribution_suite_is_blocking + workflow = File.read(File.join(ROOT, ".github/workflows/release.yml")) + build_workflow = File.read(File.join(ROOT, ".github/workflows/build-release.yml")) + install_smoke = File.read(File.join(ROOT, ".github/workflows/install-smoke.yml")) + assert_match(/needs:.*build/m, workflow) + assert_match(/gh release create/, workflow) + assert_match(/needs:.*release/m, workflow) + assert_includes workflow, "rake distribution" + assert_includes workflow, "tier1-shell" + assert_includes workflow, "tier1-homebrew" + assert_includes workflow, "tier1-aur" + assert_includes workflow, "publish-skills" + assert_includes workflow, "claude plugin marketplace add" + assert_includes workflow, "claude plugin install" + assert_includes workflow, "codex plugin add" + assert_includes workflow, "pi install" + assert_includes workflow, "subject-path: dist/hive-*.tar.gz" + publisher = File.read(File.join(ROOT, "scripts/publish-packages")) + assert_includes publisher, "scripts/verify-release" + assert_includes publisher, "--remote" + assert_includes workflow, "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4" + assert_includes build_workflow, "attestations: write" + refute_includes workflow, "ssh-keyscan aur.archlinux.org >>" + refute_match(/if:.*HOMEBREW_TAP_REPOSITORY.*AUR_REPOSITORY/, workflow) + refute_includes build_workflow, "subject-path: build/hive-" + assert_includes install_smoke, "homebrew:" + refute_includes install_smoke, "base-devel git ruby" + refute_includes install_smoke, "pacman -Rns" + end + + def test_public_docs_name_collision_consent_and_preserved_paths + readme = File.read(File.join(ROOT, "README.md")) + uninstall = File.read(File.join(ROOT, "docs/uninstall.md")) + assert_includes readme, "Homebrew" + assert_includes readme, "`hv`" + assert_includes uninstall, "preserved" + assert_includes uninstall, "agent skills" + assert_includes uninstall, "completed" + end +end diff --git a/test/acceptance/ubuntu_shell_test.rb b/test/acceptance/ubuntu_shell_test.rb new file mode 100644 index 00000000..485ae1e1 --- /dev/null +++ b/test/acceptance/ubuntu_shell_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class UbuntuShellAcceptanceTest < Minitest::Test + def test_installer_requires_manifest_digest_and_version_check + installer = File.read(File.expand_path("../../scripts/install.sh", __dir__)) + assert_includes installer, "release-manifest.json" + assert_match(/sha256sum|shasum/, installer) + assert_includes installer, "--version" + refute_match(/sudo/, installer) + end +end diff --git a/test/acceptance/uninstall_preserves_work_test.rb b/test/acceptance/uninstall_preserves_work_test.rb new file mode 100644 index 00000000..612af687 --- /dev/null +++ b/test/acceptance/uninstall_preserves_work_test.rb @@ -0,0 +1,38 @@ +require "test_helper" +require "digest" +require "hive/commands/uninstall" + +class UninstallPreservesWorkTest < Minitest::Test + include HiveTestHelper + + def test_purge_boundary_preserves_modified_unknown_state_and_skills + with_tmp_dir do |root| + FileUtils.mkdir_p(File.join(root, ".hive")) + generated = File.join(root, ".hive/generated") + modified = File.join(root, ".hive/modified") + unknown = File.join(root, ".hive/unknown") + state = File.join(root, ".hive-state/stages/8-done/task/output") + skill = File.join(root, ".agents/skills/hive/SKILL.md") + [ generated, modified, unknown, state, skill ].each { |path| FileUtils.mkdir_p(File.dirname(path)) } + File.write(generated, "owned") + File.write(modified, "changed") + File.write(unknown, "unknown") + File.write(state, "completed") + File.write(skill, "skill") + File.write(File.join(root, ".hive/ownership.yml"), { + "version" => 1, + "files" => { + ".hive/generated" => Digest::SHA256.hexdigest("owned"), + ".hive/modified" => Digest::SHA256.hexdigest("original") + } + }.to_yaml) + + Hive::Commands::Uninstall.cleanup_project(root) + refute File.exist?(generated) + assert File.exist?(modified) + assert File.exist?(unknown) + assert File.exist?(state) + assert File.exist?(skill) + end + end +end diff --git a/test/install/agent_prompt_contract_test.rb b/test/install/agent_prompt_contract_test.rb new file mode 100644 index 00000000..ac550dc8 --- /dev/null +++ b/test/install/agent_prompt_contract_test.rb @@ -0,0 +1,37 @@ +require "test_helper" +require "json" + +class AgentPromptContractTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + + def test_prompt_covers_channels_consent_and_collision_without_private_writes + prompt = File.read(File.join(ROOT, "docs/install-with-agent.md")) + assert_includes prompt, "uname -s" + assert_includes prompt, "Homebrew" + assert_includes prompt, "paru" + assert_includes prompt, "checksum-verifying Hive shell" + assert_includes prompt, "ivankuznetsov/hive" + assert_includes prompt, "ivankuznetsov/hive-skills" + assert_includes prompt, "0.1.0" + assert_includes prompt, "ask for approval" + assert_includes prompt, "use `hv`" + assert_match(/confirm the\s+> absolute Git project root/, prompt) + refute_match(%r{(?:~|\$HOME)/\.(?:claude|codex|pi)}, prompt) + end + + def test_skill_copy_and_marketplace_versions_match + canonical = File.read(File.join(ROOT, "hive-skills/skills/hive/SKILL.md")) + packaged = File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/skills/hive/SKILL.md")) + assert_equal canonical, packaged + + codex = JSON.parse(File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/.codex-plugin/plugin.json"))) + claude = JSON.parse(File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/.claude-plugin/plugin.json"))) + pi = JSON.parse(File.read(File.join(ROOT, "hive-skills/package.json"))) + adapters = JSON.parse(File.read(File.join(ROOT, "hive-skills/marketplaces/adapters.json"))) + assert_equal [ "0.1.0" ], [ codex["version"], claude["version"], pi["version"], adapters["version"] ].uniq + assert_equal Hive::VERSION, adapters["version"] + adapters.fetch("adapters").each_value do |commands| + commands.each { |command| refute_includes command, "" } + end + end +end diff --git a/test/install/package_render_test.rb b/test/install/package_render_test.rb new file mode 100644 index 00000000..b1accdb8 --- /dev/null +++ b/test/install/package_render_test.rb @@ -0,0 +1,66 @@ +require "test_helper" +require "json" +require "open3" + +class PackageRenderTest < Minitest::Test + include HiveTestHelper + + ROOT = File.expand_path("../..", __dir__) + + def test_renders_homebrew_and_aur_from_one_manifest + with_tmp_dir do |dir| + manifest = File.join(dir, "manifest.json") + output = File.join(dir, "packages") + File.write(manifest, JSON.generate( + "schema_version" => 1, + "version" => "0.1.0", + "tag" => "v0.1.0", + "repository" => "example/hive", + "ruby_version" => "3.4.1", + "installer_compatibility" => 1, + "targets" => [ + target("darwin-arm64", "mac.tar.gz", "a" * 64), + target("linux-x86_64", "linux.tar.gz", "b" * 64) + ] + )) + _out, err, status = Open3.capture3( + File.join(ROOT, "scripts/render-packages"), + "--manifest", manifest, + "--output-dir", output + ) + assert status.success?, err + formula = File.read(File.join(output, "homebrew/Formula/hive.rb")) + pkgbuild = File.read(File.join(output, "aur/PKGBUILD")) + install_hook = File.read(File.join(output, "aur/hive-bin.install")) + assert_includes formula, "sha256 \"#{'a' * 64}\"" + assert_includes pkgbuild, "sha256sums_x86_64=('#{'b' * 64}')" + assert_includes pkgbuild, "pkgname=hive-bin" + refute_match(/\bruby\b.*depends/, pkgbuild) + assert_includes formula, "prefix_hive.realpath == prefix_hv.realpath" + assert_includes formula, "File.identical?(candidate, prefix_hive)" + assert_includes formula, "command_name: \#{selected_command}" + assert_includes formula, "package: example/hive/hive" + assert_includes formula, "Optional Hive dependency missing" + assert_includes pkgbuild, "managed_files:" + assert_includes install_hook, "pre_upgrade()" + assert_includes install_hook, "s/^command_name: hive$/command_name: hv/" + _out, syntax_error, syntax_status = Open3.capture3("bash", "-n", File.join(output, "aur/PKGBUILD")) + assert syntax_status.success?, syntax_error + _out, syntax_error, syntax_status = Open3.capture3("ruby", "-c", File.join(output, "homebrew/Formula/hive.rb")) + assert syntax_status.success?, syntax_error + end + end + + private + + def target(name, asset, sha) + { + "target" => name, + "asset" => asset, + "url" => "https://example.test/#{asset}", + "sha256" => sha, + "size" => 10, + "minimum_os" => "test" + } + end +end diff --git a/test/install/shell_installer.bats b/test/install/shell_installer.bats new file mode 100755 index 00000000..35b47490 --- /dev/null +++ b/test/install/shell_installer.bats @@ -0,0 +1,33 @@ +#!/usr/bin/env bats + +setup() { + export TEST_ROOT="${BATS_TEST_TMPDIR}/case" + export HOME="${TEST_ROOT}/home" + export XDG_DATA_HOME="${TEST_ROOT}/data" + export HIVE_BIN_DIR="${TEST_ROOT}/bin" + export HIVE_INSTALL_BASE_URL="file://${TEST_ROOT}/releases" + mkdir -p "${HOME}" "${TEST_ROOT}/build" + printf '#!/bin/sh\nprintf "0.1.0\\n"\n' > "${TEST_ROOT}/build/hive-linux-x86_64" + chmod +x "${TEST_ROOT}/build/hive-linux-x86_64" + HIVE_RELEASE_REPOSITORY=example/hive \ + "${BATS_TEST_DIRNAME}/../../scripts/package-release" \ + --version 0.1.0 --build-dir "${TEST_ROOT}/build" --dist-dir "${TEST_ROOT}/releases/v0.1.0" + cp "${BATS_TEST_DIRNAME}/../../scripts/install.sh" "${TEST_ROOT}/releases/v0.1.0/install.sh" + cp "${BATS_TEST_DIRNAME}/../../scripts/uninstall-shell.sh" "${TEST_ROOT}/releases/v0.1.0/uninstall-shell.sh" +} + +@test "installs and verifies the linux artifact" { + run "${BATS_TEST_DIRNAME}/../../scripts/install.sh" --version 0.1.0 --yes + [ "$status" -eq 0 ] + [ "$("${HIVE_BIN_DIR}/hv" --version)" = "0.1.0" ] + [ -L "${HIVE_BIN_DIR}/hive" ] +} + +@test "digest mismatch preserves an existing executable" { + mkdir -p "${HIVE_BIN_DIR}" + printf 'old\n' > "${HIVE_BIN_DIR}/hv" + printf 'tamper' >> "${TEST_ROOT}/releases/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz" + run "${BATS_TEST_DIRNAME}/../../scripts/install.sh" --version 0.1.0 --yes + [ "$status" -ne 0 ] + [ "$(cat "${HIVE_BIN_DIR}/hv")" = "old" ] +} diff --git a/test/install/shell_installer_test.rb b/test/install/shell_installer_test.rb new file mode 100644 index 00000000..e2fda89f --- /dev/null +++ b/test/install/shell_installer_test.rb @@ -0,0 +1,110 @@ +require "test_helper" +require "digest" +require "open3" + +class ShellInstallerTest < Minitest::Test + include HiveTestHelper + + ROOT = File.expand_path("../..", __dir__) + + def test_public_defaults_use_the_canonical_latest_release_and_bounded_downloads + installer = File.read(File.join(ROOT, "scripts/install.sh")) + + assert_includes installer, "ivankuznetsov/hive" + assert_includes installer, "/releases/latest/download" + assert_includes installer, "--connect-timeout 10" + assert_includes installer, "--max-time 120" + assert_includes installer, "--retry 3" + refute_match(/\bsigned (?:release )?manifest\b/i, installer) + end + + def test_verified_install_and_uninstall_preserve_state + with_tmp_dir do |dir| + home = File.join(dir, "home") + build = File.join(dir, "build") + release = File.join(dir, "releases", "v0.1.0") + bin = File.join(dir, "bin") + data = File.join(dir, "data") + FileUtils.mkdir_p([ home, build, release ]) + binary = File.join(build, "hive-linux-x86_64") + File.write(binary, "#!/bin/sh\nprintf '0.1.0\\n'\n") + File.chmod(0o755, binary) + package_release(build, release) + FileUtils.cp(File.join(ROOT, "scripts/install.sh"), File.join(release, "install.sh")) + FileUtils.cp(File.join(ROOT, "scripts/uninstall-shell.sh"), File.join(release, "uninstall-shell.sh")) + env = { + "HOME" => home, + "XDG_DATA_HOME" => data, + "HIVE_BIN_DIR" => bin, + "HIVE_INSTALL_BASE_URL" => "file://#{File.join(dir, 'releases')}", + "PATH" => ENV.fetch("PATH") + } + + _out, err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes") + assert status.success?, err + assert_equal "0.1.0\n", IO.popen([ File.join(bin, "hv"), "--version" ], &:read) + receipt = File.read(File.join(data, "hive/install-receipt.yml")) + assert_includes receipt, "installer_sha256:" + assert_includes receipt, "uninstaller_sha256:" + assert_includes receipt, "executable_sha256:" + + FileUtils.rm_f(File.join(bin, "hive")) + File.write(File.join(bin, "hive"), "#!/bin/sh\necho foreign\n") + File.chmod(0o755, File.join(bin, "hive")) + foreign_digest = Digest::SHA256.file(File.join(bin, "hive")).hexdigest + _out, err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes") + assert status.success?, err + assert_equal foreign_digest, Digest::SHA256.file(File.join(bin, "hive")).hexdigest + assert_includes File.read(File.join(data, "hive/install-receipt.yml")), "command_name: hv" + + FileUtils.mkdir_p(File.join(data, "state")) + File.write(File.join(data, "state", "keep"), "work") + + _out, err, status = Open3.capture3(env, File.join(data, "hive/uninstall-shell.sh"), "--yes") + assert status.success?, err + refute File.exist?(File.join(bin, "hv")) + assert File.exist?(File.join(data, "state", "keep")) + end + end + + def test_tampered_helper_preserves_the_previous_executable + with_tmp_dir do |dir| + home = File.join(dir, "home") + build = File.join(dir, "build") + release = File.join(dir, "releases", "v0.1.0") + bin = File.join(dir, "bin") + FileUtils.mkdir_p([ home, build, release, bin ]) + binary = File.join(build, "hive-linux-x86_64") + File.write(binary, "#!/bin/sh\nprintf '0.1.0\\n'\n") + File.chmod(0o755, binary) + package_release(build, release) + File.write(File.join(bin, "hv"), "previous\n") + File.write(File.join(release, "uninstall-shell.sh"), "tampered\n") + env = { + "HOME" => home, + "XDG_DATA_HOME" => File.join(dir, "data"), + "HIVE_BIN_DIR" => bin, + "HIVE_INSTALL_BASE_URL" => "file://#{File.join(dir, 'releases')}", + "PATH" => ENV.fetch("PATH") + } + + _out, _err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes") + + refute status.success? + assert_equal "previous\n", File.read(File.join(bin, "hv")) + end + end + + private + + def package_release(build, release) + _out, err, status = Open3.capture3( + { "HIVE_RELEASE_REPOSITORY" => "example/hive" }, + File.join(ROOT, "scripts/package-release"), + "--version", "0.1.0", + "--build-dir", build, + "--dist-dir", release + ) + assert status.success?, err + end +end diff --git a/test/integration/fixtures/tui-fake-child b/test/integration/fixtures/tui-fake-child index 3a9fdbd6..11f7a2a3 100755 --- a/test/integration/fixtures/tui-fake-child +++ b/test/integration/fixtures/tui-fake-child @@ -7,6 +7,11 @@ # HIVE_TUI_FAKE_STDERR=text print to stderr # HIVE_TUI_FAKE_TRAP_INT=1 trap SIGINT, print marker, exit 130 # HIVE_TUI_FAKE_BLOCK=1 sleep until killed (used for SIGINT/SIGTERM tests) +# HIVE_TUI_FAKE_PID_FILE=path append this fixture's PID for targeted cleanup + +if [[ -n "$HIVE_TUI_FAKE_PID_FILE" ]]; then + printf '%s\n' "$$" >> "$HIVE_TUI_FAKE_PID_FILE" +fi if [[ "$HIVE_TUI_FAKE_TRAP_INT" == "1" ]]; then trap 'echo "fake-child:caught-SIGINT"; exit 130' INT diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb index 0b423861..baa0d36e 100644 --- a/test/integration/init_test.rb +++ b/test/integration/init_test.rb @@ -36,6 +36,12 @@ class InitTest < Minitest::Test projects = Hive::Config.registered_projects assert(projects.any? { |p| p["path"] == File.expand_path(dir) }) + assert_equal false, Hive::Config.load(dir).dig("daemon", "enabled") + + provider_config = YAML.safe_load_file(File.join(ENV.fetch("XDG_CONFIG_HOME"), "hive", "config.yml")) + assert_equal "openai", provider_config["provider"] + assert_equal "gpt-5", provider_config["model"] + assert_equal "env:OPENAI_API_KEY", provider_config["credential_source"] end end end @@ -113,14 +119,17 @@ class InitTest < Minitest::Test end end - def test_double_init_raises_already_initialized_with_exit_2 + def test_double_init_is_idempotent_and_repairs_missing_scaffold 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" + generated = File.join(dir, ".hive", "settings.yml") + File.delete(generated) + + _out, err = capture_io { Hive::Commands::Init.new(dir).call } + + refute_includes err, "already initialized" + assert File.file?(generated) end end end @@ -210,11 +219,11 @@ class InitTest < Minitest::Test # triage bias, 9 limit prompts, daemon-enable, confirm. Choose codex # for both, safetyist triage, only first + third reviewer, override # `plan` budget/timeout, accept the rest (daemon defaults to enabled on - # blank, confirm defaults to yes on blank). + # blank, provider/model/credential use defaults, confirmation defaults yes). inputs = [ "codex", "2", "1,3", "safetyist", "", "30,900", "", "", "", "", "", "", "", - "", "" + "", "", "", "", "" ].join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| @@ -244,7 +253,7 @@ class InitTest < Minitest::Test def test_init_with_daemon_disabled_writes_disabled_config # Same shape as above but explicitly answer `n` to the daemon prompt. - inputs = (([ "" ] * 13) + [ "n", "" ]).join("\n") + "\n" + inputs = (([ "" ] * 13) + [ "n", "", "", "", "" ]).join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| prompts = make_tty_prompts(inputs) @@ -259,8 +268,9 @@ class InitTest < Minitest::Test def test_init_aborts_with_zero_disk_state_when_user_says_n # Blank for everything until confirmation; answer `n` at the end. - # 14 blanks: planning, dev, reviewers, triage bias, 9 limits, daemon-enable. - inputs = (([ "" ] * 14) + [ "n" ]).join("\n") + "\n" + # 17 blanks: planning, dev, reviewers, triage bias, 9 limits, + # daemon-enable, provider, model, and credential environment. + inputs = (([ "" ] * 17) + [ "n" ]).join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| prompts = make_tty_prompts(inputs) @@ -301,11 +311,7 @@ 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 - Hive::Commands::Init.new(dir, prompts: prompts).call - end - assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status - assert_includes err, "already initialized" + capture_io { Hive::Commands::Init.new(dir, prompts: prompts).call } assert_equal "crash-on-this-input", input.gets&.chomp, "no input should have been consumed by the second init" end diff --git a/test/integration/packaged_entrypoint_test.rb b/test/integration/packaged_entrypoint_test.rb new file mode 100644 index 00000000..ecaaaa3d --- /dev/null +++ b/test/integration/packaged_entrypoint_test.rb @@ -0,0 +1,16 @@ +require "test_helper" +require "open3" + +class PackagedEntrypointTest < Minitest::Test + def test_version_works_outside_checkout + root = File.expand_path("../..", __dir__) + out, err, status = Open3.capture3( + { "HIVE_RESOURCE_ROOT" => root }, + File.join(root, "bin", "hive"), + "--version", + chdir: "/tmp" + ) + assert status.success?, err + assert_equal "#{Hive::VERSION}\n", out + end +end diff --git a/test/integration/tui_subprocess_test.rb b/test/integration/tui_subprocess_test.rb index 34ad39c3..2ee60de6 100644 --- a/test/integration/tui_subprocess_test.rb +++ b/test/integration/tui_subprocess_test.rb @@ -24,12 +24,12 @@ class TuiSubprocessTest < Minitest::Test # registry populated. Re-zeroing it keeps tests order-independent. Hive::Tui::SubprocessRegistry.clear %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR - HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) } + HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) } end def teardown %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR - HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) } + HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) } Hive::Tui::SubprocessRegistry.clear end @@ -239,7 +239,7 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test def setup Hive::Tui::SubprocessRegistry.clear %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR - HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) } + HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) } @messages = [] @messages_mutex = Mutex.new @dispatch = ->(msg) { @messages_mutex.synchronize { @messages << msg } } @@ -247,7 +247,7 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test def teardown %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR - HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) } + HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) } Hive::Tui::SubprocessRegistry.clear end @@ -315,18 +315,24 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test # Two slow children, dispatched back-to-back. If `dispatch_background` # is truly non-blocking, both finish in ~max(t1, t2), not t1 + t2. ENV["HIVE_TUI_FAKE_BLOCK"] = "1" - pids_killed = [] - pids_mutex = Mutex.new + pid_capture = Tempfile.new("hive-tui-background-pids") + pid_capture.close + ENV["HIVE_TUI_FAKE_PID_FILE"] = pid_capture.path Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "develop" ], dispatch: @dispatch) Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "review" ], dispatch: @dispatch) - # Kill any blocking child after a brief moment so the reapers can fire. + # Target only the children spawned by this test. This keeps the fixture + # portable to minimal hosts that do not provide procps/pgrep. killer = Thread.new do - sleep 0.3 - `pgrep -f tui-fake-child`.split.each do |pid| + deadline = Time.now + 3.0 + pids = [] + until pids.length == 2 || Time.now > deadline + pids = File.readlines(pid_capture.path, chomp: true).reject(&:empty?) + sleep 0.02 if pids.length < 2 + end + pids.each do |pid| Process.kill("TERM", pid.to_i) - pids_mutex.synchronize { pids_killed << pid.to_i } rescue Errno::ESRCH nil end @@ -338,6 +344,8 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test verbs = @messages.map(&:verb).sort assert_equal %w[develop review], verbs, "each dispatch must surface its own verb on completion (no cross-talk)" + ensure + pid_capture&.unlink end # ---- Verb caching from argv[1] ---- diff --git a/test/release/manifest_contract_test.rb b/test/release/manifest_contract_test.rb new file mode 100644 index 00000000..45ad1adb --- /dev/null +++ b/test/release/manifest_contract_test.rb @@ -0,0 +1,95 @@ +require "test_helper" +require "json" +require "json_schemer" +require "open3" +require "digest" + +class ReleaseManifestContractTest < Minitest::Test + include HiveTestHelper + + ROOT = File.expand_path("../..", __dir__) + + def test_package_release_emits_valid_digest_and_metadata + with_tmp_dir do |dir| + build = File.join(dir, "build") + dist = File.join(dir, "dist") + FileUtils.mkdir_p(build) + File.write(File.join(build, "hive-linux-x86_64"), "#!/bin/sh\necho 0.1.0\n") + File.chmod(0o755, File.join(build, "hive-linux-x86_64")) + File.write(File.join(build, "hive-linux-x86_64.spdx.json"), "{}\n") + + _out, err, status = Open3.capture3( + { "HIVE_RELEASE_REPOSITORY" => "example/hive" }, + File.join(ROOT, "scripts", "package-release"), + "--version", "0.1.0", + "--build-dir", build, + "--dist-dir", dist + ) + assert status.success?, err + manifest = JSON.parse(File.read(File.join(dist, "release-manifest.json"))) + schema = JSONSchemer.schema(JSON.parse(File.read(File.join(ROOT, "packaging/release-manifest.schema.json")))) + assert_empty schema.validate(manifest).to_a + target = manifest.fetch("targets").first + archive = File.join(dist, target.fetch("asset")) + assert_equal Digest::SHA256.file(archive).hexdigest, target.fetch("sha256") + assert_equal File.size(archive), target.fetch("size") + assert_equal %w[install.sh uninstall-shell.sh], + manifest.fetch("shell_assets").map { |row| row.fetch("asset") }.sort + manifest.fetch("shell_assets").each do |row| + path = File.join(dist, row.fetch("asset")) + assert_equal Digest::SHA256.file(path).hexdigest, row.fetch("sha256") + assert_equal File.size(path), row.fetch("size") + end + end + end + + def test_non_semver_is_rejected + _out, _err, status = Open3.capture3(File.join(ROOT, "scripts", "build-release"), "--version", "main") + refute status.success? + end + + def test_remote_verification_downloads_and_hashes_public_asset_bytes + with_tmp_dir do |dir| + build = File.join(dir, "build") + dist = File.join(dir, "dist") + remote = File.join(dir, "remote") + FileUtils.mkdir_p(build) + %w[darwin-arm64 linux-x86_64].each do |target| + path = File.join(build, "hive-#{target}") + File.write(path, "#!/bin/sh\necho 0.1.0\n") + File.chmod(0o755, path) + end + _out, err, status = Open3.capture3( + { "HIVE_RELEASE_REPOSITORY" => "example/hive" }, + File.join(ROOT, "scripts", "package-release"), + "--version", "0.1.0", + "--build-dir", build, + "--dist-dir", dist + ) + assert status.success?, err + FileUtils.cp_r(dist, remote) + + manifest_path = File.join(dist, "release-manifest.json") + manifest = JSON.parse(File.read(manifest_path)) + (manifest.fetch("targets") + manifest.fetch("shell_assets")).each do |row| + row["url"] = "file://#{File.join(remote, row.fetch('asset'))}" + end + File.write(manifest_path, JSON.pretty_generate(manifest)) + + _out, err, status = Open3.capture3( + File.join(ROOT, "scripts", "verify-release"), + "--manifest", manifest_path, + "--remote" + ) + assert status.success?, err + + File.open(File.join(remote, manifest.fetch("targets").first.fetch("asset")), "ab") { |file| file << "tampered" } + _out, _err, status = Open3.capture3( + File.join(ROOT, "scripts", "verify-release"), + "--manifest", manifest_path, + "--remote" + ) + refute status.success? + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index d251d52b..65aec42b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -70,13 +70,22 @@ module HiveTestHelper def with_tmp_global_config dir = Dir.mktmpdir("hive-global") - old = ENV["HIVE_HOME"] + env_keys = %w[ + HIVE_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME + XDG_CACHE_HOME XDG_RUNTIME_DIR + ] + old = env_keys.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") + ENV["XDG_RUNTIME_DIR"] = File.join(dir, "xdg-runtime") File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml) begin yield(dir) ensure - ENV["HIVE_HOME"] = old + old.each { |key, value| value ? ENV[key] = value : ENV.delete(key) } # 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..857e715f --- /dev/null +++ b/test/unit/assets_test.rb @@ -0,0 +1,63 @@ +require "test_helper" +require "hive/assets" + +class HiveAssetsTest < Minitest::Test + include HiveTestHelper + + def test_seed_and_scaffold_are_idempotent_and_preserve_edits + with_tmp_dir do |dir| + data = File.join(dir, "data") + project = File.join(dir, "project") + source = File.join(dir, "source") + FileUtils.mkdir_p(source) + File.write(File.join(source, "hello.txt"), "original\n") + FileUtils.mkdir_p(project) + + Hive::Assets.seed_shared!(source: source, destination: data, version: "0.1.0") + result = Hive::Assets.scaffold_project!(project, source: source) + generated = File.join(project, ".hive", "hello.txt") + assert_equal [ ".hive/hello.txt" ], result[:created] + assert_equal "original\n", File.read(generated) + + File.write(generated, "user edit\n") + second = Hive::Assets.scaffold_project!(project, source: source) + assert_equal [ ".hive/hello.txt" ], second[:conflicts] + assert_equal "user edit\n", File.read(generated) + end + end + + def test_seed_repairs_the_current_pointer_for_an_existing_complete_release + with_tmp_dir do |dir| + source = File.join(dir, "source") + release = File.join(dir, "releases", "0.1.0") + FileUtils.mkdir_p([ source, release ]) + File.write(File.join(source, "shared.txt"), "shared\n") + File.write(File.join(release, ".complete"), "0.1.0\n") + File.symlink("0.0.9", File.join(dir, "releases", "current")) + + Hive::Assets.seed_shared!(source: source, destination: File.join(dir, "releases"), version: "0.1.0") + + assert_equal "0.1.0", File.readlink(File.join(dir, "releases", "current")) + end + end + + def test_scaffold_rejects_a_symlinked_hive_directory + with_tmp_dir do |dir| + project = File.join(dir, "project") + outside = File.join(dir, "outside") + source = File.join(dir, "source") + FileUtils.mkdir_p([ project, outside, source ]) + File.write(File.join(source, "settings.yml"), "version: 1\n") + File.symlink(outside, File.join(project, ".hive")) + + assert_raises(Hive::ConfigError) do + Hive::Assets.scaffold_project!(project, source: source) + end + refute File.exist?(File.join(outside, "settings.yml")) + end + end + + def test_rejects_broad_project_root + assert_raises(Hive::ConfigError) { Hive::Assets.scaffold_project!("/") } + end +end diff --git a/test/unit/command_name_test.rb b/test/unit/command_name_test.rb new file mode 100644 index 00000000..4f310248 --- /dev/null +++ b/test/unit/command_name_test.rb @@ -0,0 +1,30 @@ +require "test_helper" +require "hive/command_name" + +class HiveCommandNameTest < Minitest::Test + include HiveTestHelper + + def test_selects_hive_when_unclaimed + with_tmp_dir do |dir| + assert_equal "hive", Hive::CommandName.select(path: dir) + end + end + + def test_selects_hv_when_foreign_hive_is_on_path + with_tmp_dir do |dir| + File.write(File.join(dir, "hive"), "#!/bin/sh\n") + File.chmod(0o755, File.join(dir, "hive")) + assert_equal "hv", Hive::CommandName.select(path: dir, installed_executable: "/elsewhere/hv") + end + end + + def test_stable_executable_prefers_the_path_resolved_command + with_tmp_dir do |dir| + stable = File.join(dir, "hv") + File.write(stable, "#!/bin/sh\n") + File.chmod(0o755, stable) + + assert_equal stable, Hive::CommandName.stable_executable(path: dir, current: "/cellar/hive/0.1.0/bin/hv") + end + end +end diff --git a/test/unit/commands/init/prompts_test.rb b/test/unit/commands/init/prompts_test.rb index 734b955d..bdfdbadc 100644 --- a/test/unit/commands/init/prompts_test.rb +++ b/test/unit/commands/init/prompts_test.rb @@ -34,7 +34,7 @@ class InitPromptsTest < Minitest::Test # Build the canonical "all defaults" answer key set so tests can assert # against the same shape Init#call will pass to the ERB template. - def all_defaults + def all_defaults(daemon_enabled: false) { "planning_agent" => "claude", "development_agent" => "codex", @@ -46,7 +46,10 @@ class InitPromptsTest < Minitest::Test "timeouts" => Hive::Commands::Init::Prompts::LIMIT_KEYS.each_with_object({}) do |k, h| h[k] = Hive::Config::DEFAULTS["timeout_sec"][k] end, - "daemon_enabled" => true + "daemon_enabled" => daemon_enabled, + "provider" => "openai", + "model" => "gpt-5", + "credential_env" => "OPENAI_API_KEY" } end @@ -59,11 +62,16 @@ class InitPromptsTest < Minitest::Test # open_pr, finalize, review_ci, review_triage, review_fix, # review_browser) # 14. daemon enable Y/n (added under ADR-024) - # 15. confirmation Y/n + # 15. provider + # 16. model + # 17. credential environment variable + # 18. confirmation Y/n # Each line is one answer; blank line = accept default. def interactive_input(planning: "", development: "", reviewers: "", - triage_bias: "", limits: ([ "" ] * 9), daemon: "", confirm: "") - [ planning, development, reviewers, triage_bias, *limits, daemon, confirm ].map { |a| "#{a}\n" }.join + triage_bias: "", limits: ([ "" ] * 9), daemon: "", + provider: "", model: "", credential_env: "", confirm: "") + [ planning, development, reviewers, triage_bias, *limits, daemon, + provider, model, credential_env, confirm ].map { |a| "#{a}\n" }.join end # --- non-TTY: short-circuit to defaults ---------------------------------- @@ -111,7 +119,7 @@ class InitPromptsTest < Minitest::Test def test_interactive_all_defaults prompts, _output = make_prompts(interactive_input) answers = prompts.collect - assert_equal all_defaults, answers + assert_equal all_defaults(daemon_enabled: true), answers end def test_interactive_all_defaults_summary_says_so @@ -138,10 +146,10 @@ class InitPromptsTest < Minitest::Test def test_interactive_planning_agent_unknown_reprompts_then_accepts # First answer is invalid → re-prompt; second answer is valid. - # 16 reads total: planning (invalid + retry) + dev + reviewers - # + triage bias + 9 limits + daemon + confirm. Each blank line + # 19 reads total: planning (invalid + retry) + dev + reviewers + # + triage bias + 9 limits + daemon + provider/model/credential + confirm. Each blank line # accepts the default. - raw = ([ "nonexistent", "claude" ] + ([ "" ] * 14)).join("\n") + "\n" + raw = ([ "nonexistent", "claude" ] + ([ "" ] * 17)).join("\n") + "\n" prompts, output, _summary = make_prompts(raw) answers = prompts.collect assert_equal "claude", answers["planning_agent"] @@ -149,7 +157,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_planning_agent_index_out_of_range_reprompts - raw = ([ "7", "claude" ] + ([ "" ] * 14)).join("\n") + "\n" + raw = ([ "7", "claude" ] + ([ "" ] * 17)).join("\n") + "\n" prompts, output, _summary = make_prompts(raw) answers = prompts.collect assert_equal "claude", answers["planning_agent"] @@ -189,7 +197,7 @@ class InitPromptsTest < Minitest::Test # Build the input manually since interactive_input doesn't allow # multi-line reviewer answers cleanly. Trailing values are triage # bias, nine limits, daemon, and confirm. - input = ([ "", "", "7", "1,2" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", "7", "1,2" ] + ([ "" ] * 15)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review codex-ce-code-review], answers["enabled_reviewers"] @@ -197,7 +205,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_reviewers_unknown_name_reprompts - input = ([ "", "", "nope", "1" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", "nope", "1" ] + ([ "" ] * 15)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"] @@ -231,7 +239,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_triage_bias_unknown_reprompts - input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 11)).join("\n") + "\n" + input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 14)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal "safetyist", answers["triage_bias"] @@ -277,8 +285,8 @@ class InitPromptsTest < Minitest::Test def test_interactive_limits_zero_budget_reprompts # First answer 0,300 fails validation → re-prompt; second answer 10,600 accepted. - # Trailing pair is daemon + confirm (both blank → daemon enabled, confirm yes). - input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 10)).join("\n") + "\n" + # Trailing values cover the remaining limits, daemon, provider settings, and confirmation. + input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 13)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal 10, answers["budgets"]["brainstorm"] @@ -288,7 +296,7 @@ class InitPromptsTest < Minitest::Test def test_interactive_limits_malformed_format_reprompts # "30" without comma fails the , shape → re-prompt - input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 10)).join("\n") + "\n" + input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 13)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal 30, answers["budgets"]["brainstorm"] @@ -347,7 +355,7 @@ class InitPromptsTest < Minitest::Test # daemon slot. interactive_input(daemon: "maybe") provides "maybe" + "" (confirm). # We need: maybe (rejected) → y (accepted as daemon=enabled) → blank (confirm). # So push two extra reads in. - input = interactive_input(daemon: "maybe", confirm: "") + "y\n\n" + input = ([ "" ] * 13 + [ "maybe", "y", "", "", "", "" ]).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal true, answers["daemon_enabled"] @@ -361,17 +369,16 @@ class InitPromptsTest < Minitest::Test assert_match(/daemon\s+= disabled/, summary) end - def test_non_tty_default_includes_daemon_enabled_true + def test_non_tty_default_keeps_daemon_disabled_without_consent prompts, _output, _summary = make_prompts("", tty: false) answers = prompts.collect - assert_equal true, answers["daemon_enabled"], - "non-TTY callers should land on the same recommended default as TTY (Y)" + assert_equal false, answers["daemon_enabled"] end def test_non_tty_summary_includes_daemon_state prompts, _output, summary_io = make_prompts("", tty: false) prompts.collect - assert_match(/daemon=enabled/, summary_io.string) + assert_match(/daemon=disabled/, summary_io.string) end def test_non_tty_default_includes_triage_bias_courageous @@ -465,7 +472,7 @@ class InitPromptsTest < Minitest::Test # Truncate the input transcript right before confirmation. read_line # must distinguish nil-from-gets (EOF) from an empty line and bubble # Aborted up the stack rather than silently confirming. - inputs = ([ "" ] * 14).join("\n") + "\n" # 14 blank reads, then EOF + inputs = ([ "" ] * 17).join("\n") + "\n" # 17 blank reads, then EOF prompts, _output, _summary = make_prompts(inputs) assert_raises(Hive::Commands::Init::Prompts::Aborted) { prompts.collect } end @@ -493,8 +500,8 @@ class InitPromptsTest < Minitest::Test # that would produce an invalid YAML key (parses to nil) which # validate_reviewers! rejects on the next `hive run`. Re-prompt instead. def test_reviewers_comma_only_reprompts - # Trailing values are triage bias, nine limits, daemon, and confirm. - input = ([ "", "", ",", "1" ] + ([ "" ] * 12)).join("\n") + "\n" + # Trailing values are triage bias, nine limits, daemon, provider settings, and confirmation. + input = ([ "", "", ",", "1" ] + ([ "" ] * 15)).join("\n") + "\n" prompts, output, _summary = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"] @@ -502,7 +509,7 @@ class InitPromptsTest < Minitest::Test end def test_reviewers_whitespace_only_reprompts - input = ([ "", "", " , , ", "2" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", " , , ", "2" ] + ([ "" ] * 15)).join("\n") + "\n" prompts, output, _summary = make_prompts(input) answers = prompts.collect assert_equal %w[codex-ce-code-review], answers["enabled_reviewers"] diff --git a/test/unit/dependency_check_test.rb b/test/unit/dependency_check_test.rb new file mode 100644 index 00000000..0ec0753b --- /dev/null +++ b/test/unit/dependency_check_test.rb @@ -0,0 +1,11 @@ +require "test_helper" +require "hive/dependency_check" + +class HiveDependencyCheckTest < Minitest::Test + def test_reports_missing_tools_without_installing + rows = Hive::DependencyCheck.call(path: "") + assert_equal %w[git bash claude gh jq], rows.map { |row| row[:name] } + assert rows.all? { |row| row[:status] == :missing } + assert rows.all? { |row| row[:hint].include?("Install") } + end +end diff --git a/test/unit/first_run_config_test.rb b/test/unit/first_run_config_test.rb new file mode 100644 index 00000000..83d932f2 --- /dev/null +++ b/test/unit/first_run_config_test.rb @@ -0,0 +1,53 @@ +require "test_helper" +require "hive/config" + +class FirstRunConfigTest < Minitest::Test + include HiveTestHelper + + def test_writes_choices_and_credential_reference_but_not_secret + with_tmp_dir do |dir| + path = File.join(dir, "config.yml") + Hive::Config.write_provider_config!( + path: path, + provider: "openai", + model: "gpt-5", + credential_env: "OPENAI_API_KEY" + ) + data = YAML.safe_load_file(path) + assert_equal "openai", data["provider"] + assert_equal "env:OPENAI_API_KEY", data["credential_source"] + assert_equal 0o600, File.stat(path).mode & 0o777 + end + end + + def test_rejects_raw_secret_values + with_tmp_dir do |dir| + assert_raises(Hive::ConfigError) do + Hive::Config.write_provider_config!( + path: File.join(dir, "config.yml"), + provider: "openai", + model: "gpt-5", + credential_env: "sk-secret-value" + ) + end + end + end + + def test_preserves_unrelated_existing_xdg_configuration + with_tmp_dir do |dir| + path = File.join(dir, "config.yml") + File.write(path, { "theme" => "dark", "provider" => "legacy" }.to_yaml) + + Hive::Config.write_provider_config!( + path: path, + provider: "openai", + model: "gpt-5", + credential_env: "OPENAI_API_KEY" + ) + + data = YAML.safe_load_file(path) + assert_equal "dark", data["theme"] + assert_equal "openai", data["provider"] + end + end +end diff --git a/test/unit/install_channel_test.rb b/test/unit/install_channel_test.rb new file mode 100644 index 00000000..d3cf0c71 --- /dev/null +++ b/test/unit/install_channel_test.rb @@ -0,0 +1,105 @@ +require "test_helper" +require "hive/install/channel" + +class InstallChannelTest < Minitest::Test + class FakeRunner + attr_reader :commands + + def initialize(outputs: {}) + @commands = [] + @outputs = outputs + end + + def call(*command) + @commands << command + [ @outputs.fetch(command, ""), "", Struct.new(:success?).new(true) ] + end + end + + def test_homebrew_update_delegates_without_writing_executable + runner = FakeRunner.new + receipt = { + "schema_version" => 1, + "channel" => "homebrew", + "package" => "hive/tap/hive", + "command_name" => "hive", + "executable" => "/opt/homebrew/opt/hive/bin/hv", + "release_source" => "https://example.test/release", + "managed_files" => [ "/opt/homebrew/opt/hive/bin/hv" ] + } + Hive::Install::Channel.new(receipt, runner: runner).update + assert_equal [ "brew", "upgrade", "hive/tap/hive" ], runner.commands.last + end + + def test_homebrew_ownership_requires_the_receipt_executable_to_match_the_formula_prefix + Dir.mktmpdir("hive-brew-prefix") do |prefix| + executable = File.join(prefix, "bin", "hv") + FileUtils.mkdir_p(File.dirname(executable)) + File.write(executable, "#!/bin/sh\n") + runner = FakeRunner.new(outputs: { + [ "brew", "--prefix", "hive" ] => "#{prefix}\n" + }) + receipt = { + "schema_version" => 1, + "channel" => "homebrew", + "package" => "hive", + "command_name" => "hv", + "executable" => executable, + "release_source" => "https://example.test/release", + "managed_files" => [ executable ] + } + + assert Hive::Install::Channel.new(receipt, runner: runner).verify_ownership! + end + end + + def test_aur_selects_available_helper + runner = FakeRunner.new + receipt = { + "schema_version" => 1, + "channel" => "aur", + "package" => "hive-bin", + "command_name" => "hive", + "executable" => "/usr/bin/hv", + "release_source" => "https://example.test/release", + "managed_files" => [ "/usr/bin/hv" ] + } + channel = Hive::Install::Channel.new(receipt, runner: runner, resolver: ->(name) { name == "paru" ? "/bin/paru" : nil }) + channel.update + assert_equal [ "/bin/paru", "-S", "--needed", "hive-bin" ], runner.commands.last + end + + def test_aur_uninstall_uses_the_available_helper + runner = FakeRunner.new + receipt = { + "schema_version" => 1, + "channel" => "aur", + "package" => "hive-bin", + "command_name" => "hv", + "executable" => "/usr/bin/hv", + "release_source" => "https://example.test/release", + "managed_files" => [ "/usr/bin/hv" ] + } + channel = Hive::Install::Channel.new(receipt, runner: runner, resolver: ->(name) { name == "yay" ? "/bin/yay" : nil }) + + channel.uninstall + + assert_equal [ "/bin/yay", "-Rns", "--noconfirm", "hive-bin" ], runner.commands.last + end + + def test_aur_ownership_must_match_the_receipt_package + command = [ "pacman", "-Qqo", "/usr/bin/hv" ] + runner = FakeRunner.new(outputs: { command => "other-package\n" }) + receipt = { + "schema_version" => 1, + "channel" => "aur", + "package" => "hive-bin", + "command_name" => "hv", + "executable" => "/usr/bin/hv", + "release_source" => "https://example.test/release", + "managed_files" => [ "/usr/bin/hv" ] + } + + assert_raises(Hive::ConfigError) { Hive::Install::Channel.new(receipt, runner: runner).verify_ownership! } + end +end diff --git a/test/unit/install_receipt_test.rb b/test/unit/install_receipt_test.rb new file mode 100644 index 00000000..9a84ba0e --- /dev/null +++ b/test/unit/install_receipt_test.rb @@ -0,0 +1,55 @@ +require "test_helper" +require "hive/install/receipt" + +class InstallReceiptTest < Minitest::Test + include HiveTestHelper + + def test_round_trip_and_validation + with_tmp_dir do |dir| + path = File.join(dir, "receipt.yml") + receipt = Hive::Install::Receipt.write!( + path: path, + data: { + "channel" => "shell", + "package" => "hive", + "command_name" => "hv", + "executable" => File.join(dir, "hv"), + "executable_sha256" => "a" * 64, + "release_source" => "https://example.test/v0.1.0", + "installer" => File.join(dir, "install.sh"), + "installer_sha256" => "b" * 64, + "uninstaller" => File.join(dir, "uninstall-shell.sh"), + "uninstaller_sha256" => "c" * 64, + "managed_files" => [ + File.join(dir, "hv"), + File.join(dir, "install.sh"), + File.join(dir, "uninstall-shell.sh") + ] + } + ) + assert_equal "shell", receipt["channel"] + assert_equal 1, Hive::Install::Receipt.load(path)["schema_version"] + end + end + + def test_rejects_unknown_channel + error = assert_raises(Hive::ConfigError) do + Hive::Install::Receipt.validate!("channel" => "guess") + end + assert_includes error.message, "channel" + end + + def test_rejects_missing_schema_and_shell_helper_fields + base = { + "channel" => "shell", + "package" => "hive", + "command_name" => "hv", + "executable" => "/tmp/hv", + "release_source" => "https://example.test/v0.1.0" + } + assert_raises(Hive::ConfigError) { Hive::Install::Receipt.validate!(base) } + assert_raises(Hive::ConfigError) do + Hive::Install::Receipt.validate!(base.merge("schema_version" => 1)) + end + end +end diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb new file mode 100644 index 00000000..c7fef95c --- /dev/null +++ b/test/unit/paths_test.rb @@ -0,0 +1,55 @@ +require "test_helper" +require "hive/paths" + +class HivePathsTest < Minitest::Test + include HiveTestHelper + + def test_standard_xdg_defaults + with_env( + "HOME" => "/tmp/hive-home", + "XDG_CONFIG_HOME" => nil, + "XDG_DATA_HOME" => nil, + "XDG_STATE_HOME" => nil, + "XDG_CACHE_HOME" => nil, + "XDG_RUNTIME_DIR" => nil + ) do + assert_equal "/tmp/hive-home/.config/hive", Hive::Paths.config_dir + assert_equal "/tmp/hive-home/.local/share/hive", Hive::Paths.data_dir + assert_equal "/tmp/hive-home/.local/state/hive", Hive::Paths.state_dir + assert_equal "/tmp/hive-home/.cache/hive", Hive::Paths.cache_dir + assert_equal "/tmp/hive-home/.local/state/hive/run", Hive::Paths.runtime_dir + end + end + + def test_xdg_overrides + with_env( + "XDG_CONFIG_HOME" => "/c", + "XDG_DATA_HOME" => "/d", + "XDG_STATE_HOME" => "/s", + "XDG_CACHE_HOME" => "/k", + "XDG_RUNTIME_DIR" => "/r" + ) do + assert_equal "/c/hive", Hive::Paths.config_dir + assert_equal "/d/hive", Hive::Paths.data_dir + assert_equal "/s/hive", Hive::Paths.state_dir + assert_equal "/k/hive", Hive::Paths.cache_dir + assert_equal "/r/hive", Hive::Paths.runtime_dir + end + end + + def test_service_state_override_is_consumed + with_env("HIVE_STATE_DIR" => "/service/state", "XDG_STATE_HOME" => "/ignored") do + assert_equal "/service/state", Hive::Paths.state_dir + end + end + + private + + def with_env(values) + old = values.to_h { |key, _| [ key, ENV[key] ] } + values.each { |key, value| value ? ENV[key] = value : ENV.delete(key) } + yield + ensure + old.each { |key, value| value ? ENV[key] = value : ENV.delete(key) } + end +end diff --git a/test/unit/service_test.rb b/test/unit/service_test.rb new file mode 100644 index 00000000..2a39742f --- /dev/null +++ b/test/unit/service_test.rb @@ -0,0 +1,99 @@ +require "test_helper" +require "hive/service" + +class HiveServiceTest < Minitest::Test + include HiveTestHelper + + class FakeRunner + attr_reader :commands + + def initialize(success: true) + @success = success + @commands = [] + end + + def call(*command) + @commands << command + [ "", "", Struct.new(:success?).new(@success) ] + end + end + + class SequenceRunner + def initialize(*results) + @results = results + end + + def call(*) + success = @results.shift + [ "", "reload failed", Struct.new(:success?).new(success) ] + end + end + + def test_systemd_registers_stopped_then_starts_on_request + with_tmp_dir do |dir| + runner = FakeRunner.new + service = Hive::Services::SystemdUser.new( + executable: "/opt/hive/bin/hv", + config_home: dir, + runner: runner + ) + service.register + assert File.file?(File.join(dir, "systemd/user/hive.service")) + assert_equal [ [ "systemctl", "--user", "daemon-reload" ] ], runner.commands + + service.start + assert_equal [ "systemctl", "--user", "enable", "--now", "hive.service" ], runner.commands.last + end + end + + def test_launchd_registration_is_idempotent_and_updates_executable + with_tmp_dir do |dir| + runner = FakeRunner.new + service = Hive::Services::Launchd.new( + executable: "/one/hive", + home: dir, + runner: runner + ) + service.register + path = File.join(dir, "Library/LaunchAgents/org.hive.daemon.plist") + assert_includes File.read(path), "/one/hive" + + Hive::Services::Launchd.new(executable: "/two/hv", home: dir, runner: runner).register + assert_includes File.read(path), "/two/hv" + refute_includes File.read(path), "/one/hive" + end + end + + def test_systemd_registration_failure_removes_new_definition + with_tmp_dir do |dir| + service = Hive::Services::SystemdUser.new( + executable: "/opt/hive/bin/hv", + config_home: dir, + runner: SequenceRunner.new(false, true) + ) + + assert_raises(Hive::Error) { service.register } + refute File.exist?(File.join(dir, "systemd/user/hive.service")) + end + end + + def test_unregister_without_a_definition_is_a_manager_independent_no_op + with_tmp_dir do |dir| + systemd_runner = FakeRunner.new(success: false) + launchd_runner = FakeRunner.new(success: false) + + assert_equal :unregistered, Hive::Services::SystemdUser.new( + executable: "/opt/hive/bin/hv", + config_home: dir, + runner: systemd_runner + ).unregister + assert_equal :unregistered, Hive::Services::Launchd.new( + executable: "/opt/hive/bin/hv", + home: dir, + runner: launchd_runner + ).unregister + assert_empty systemd_runner.commands + assert_empty launchd_runner.commands + end + end +end diff --git a/test/unit/uninstall_test.rb b/test/unit/uninstall_test.rb new file mode 100644 index 00000000..a7db29bc --- /dev/null +++ b/test/unit/uninstall_test.rb @@ -0,0 +1,100 @@ +require "test_helper" +require "digest" +require "stringio" +require "hive/commands/uninstall" + +class UninstallTest < Minitest::Test + include HiveTestHelper + + def test_cleanup_removes_only_unmodified_owned_files + with_tmp_dir do |dir| + hive_dir = File.join(dir, ".hive") + FileUtils.mkdir_p(hive_dir) + generated = File.join(hive_dir, "generated.yml") + modified = File.join(hive_dir, "modified.yml") + unknown = File.join(hive_dir, "unknown.yml") + File.write(generated, "generated\n") + File.write(modified, "changed\n") + File.write(unknown, "unknown\n") + manifest = { + "version" => 1, + "files" => { + ".hive/generated.yml" => Digest::SHA256.file(generated).hexdigest, + ".hive/modified.yml" => Digest::SHA256.hexdigest("original\n"), + "../escape" => Digest::SHA256.hexdigest("x") + } + } + File.write(File.join(hive_dir, "ownership.yml"), manifest.to_yaml) + + result = Hive::Commands::Uninstall.cleanup_project(dir) + refute File.exist?(generated) + assert File.exist?(modified) + assert File.exist?(unknown) + assert_includes result[:preserved], ".hive/modified.yml" + assert_includes result[:preserved], "../escape" + end + end + + def test_preview_and_service_unregister_precede_package_and_project_mutation + events = [] + output = StringIO.new + output.define_singleton_method(:puts) do |line| + events << :preview + super(line) + end + channel = Object.new + channel.define_singleton_method(:verify_ownership!) { events << :verify } + channel.define_singleton_method(:uninstall) { events << :package } + service = Object.new + service.define_singleton_method(:unregister) { events << :service } + receipt = { "channel" => "shell", "package" => "hive" } + + Hive::Commands::Uninstall.new( + output: output, + receipt_loader: ->(_path) { receipt }, + channel_factory: ->(_loaded) { channel }, + service: service + ).call + + assert_operator events.index(:preview), :<, events.index(:service) + assert_operator events.index(:service), :<, events.index(:package) + end + + def test_unregister_failure_stops_package_and_project_removal + events = [] + channel = Object.new + channel.define_singleton_method(:verify_ownership!) { events << :verify } + channel.define_singleton_method(:uninstall) { events << :package } + service = Object.new + service.define_singleton_method(:unregister) do + events << :service + raise Hive::Error, "reload failed" + end + receipt = { "channel" => "shell", "package" => "hive" } + + with_tmp_dir do |dir| + hive_dir = File.join(dir, ".hive") + FileUtils.mkdir_p(hive_dir) + generated = File.join(hive_dir, "generated.yml") + File.write(generated, "generated\n") + File.write(File.join(hive_dir, "ownership.yml"), { + "version" => 1, + "files" => { ".hive/generated.yml" => Digest::SHA256.file(generated).hexdigest } + }.to_yaml) + + assert_raises(Hive::Error) do + Hive::Commands::Uninstall.new( + project_root: dir, + cleanup: true, + output: StringIO.new, + receipt_loader: ->(_path) { receipt }, + channel_factory: ->(_loaded) { channel }, + service: service + ).call + end + + assert_equal %i[verify service], events + assert File.exist?(generated) + end + end +end diff --git a/wiki/active-areas.md b/wiki/active-areas.md index 46a64712..0dd7a312 100644 --- a/wiki/active-areas.md +++ b/wiki/active-areas.md @@ -29,7 +29,7 @@ Working tree clean as of 2026-04-25. Three commits on `main`: | CI | `.github/workflows/ci.yml`, `.github/dependabot.yml`, `config/brakeman.ignore` | Wired | | Repo hygiene | `CHANGELOG.md`, `SECURITY.md`, `.github/ISSUE_TEMPLATE/`, `.github/PULL_REQUEST_TEMPLATE.md` | Authored | | Docs | `README.md`, `wiki/` knowledge base | Authored | -| Daemon (ADR-024) | `lib/hive/daemon/*`, `lib/hive/commands/daemon.rb`, `wiki/commands/daemon.md`, `wiki/modules/daemon.md` | Auto-advancing dispatcher: polls `hive status --json`, fires workflow verbs on tasks ready to advance, auto-archives 7-finalize after PR merge via `gh pr view`. Per-project enrolled at `hive init` (default Y). | +| Daemon (ADR-024) | `lib/hive/daemon/*`, `lib/hive/commands/daemon.rb`, `wiki/commands/daemon.md`, `wiki/modules/daemon.md` | Auto-advancing dispatcher: polls `hive status --json`, fires workflow verbs on tasks ready to advance, auto-archives 7-finalize after PR merge via `gh pr view`. Interactive init asks separately; non-TTY init leaves enrollment disabled. | | Telegram bot (ADR-026) | `lib/hive/bot/*`, `lib/hive/commands/bot.rb`, `wiki/commands/bot.md`, `wiki/modules/bot.md` | Mobile human-input surface: long-polls Telegram, notifies on waiting/recovery gates, writes brainstorm answers under lock, and dispatches existing `hive` commands from inline buttons. | ## Phase 1 deferred work diff --git a/wiki/architecture.md b/wiki/architecture.md index bcd5de15..91b0a65c 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -9,6 +9,82 @@ tags: [architecture, overview] **TLDR**: Hive is a Ruby 3.4 / Thor control plane over an eight-stage filesystem state machine. The CLI dispatches into per-stage runners; stage agents run through configured AgentProfile CLIs inside per-task and per-project locks. Optional long-running surfaces sit beside the CLI: `hive daemon` advances safe tasks automatically, `hive tui` renders a terminal dashboard, and `hive bot` turns human-input gates into Telegram interactions. There is still no database; durable state is the filesystem plus global YAML config. +Packaged builds resolve code and bundled assets from `HIVE_RESOURCE_ROOT` (or +the executable's repository root in source checkouts), so command behavior no +longer depends on the current working directory. Runtime-owned global paths use +the XDG resolver in `Hive::Paths`: config, data, state, cache, and runtime files +have distinct roots, while `HIVE_HOME` remains a legacy test/operator override. +`HIVE_STATE_DIR` is the service-safe state override. Existing per-project +`.hive-state` behavior remains project-local. + +`hive init` additionally seeds immutable shared and project assets under +`$XDG_DATA_HOME/hive/releases/`, repairs the managed `current` +pointer, and scaffolds `.hive/` only from that immutable release. Generated +file digests live in `.hive/ownership.yml`; edits, symlinks, and unrelated +content are preserved, and a symlinked destination is rejected before writes. +Provider configuration stores only an environment-variable reference, merges +with existing global YAML, and uses mode `0600`. + +Per-user daemon registration is isolated behind `Hive::Service`. Linux renders +`~/.config/systemd/user/hive.service`; macOS renders +`~/Library/LaunchAgents/org.hive.daemon.plist`. Definitions execute the stable +channel command rather than a versioned package path. Registration is +idempotent, rolls its definition back when manager reload fails, and is stopped +by default; the separate init daemon-consent answer controls whether the native +manager starts it. + +Install ownership is explicit in a versioned, channel-specific XDG data +receipt. Before update or uninstall, Hive cross-checks the receipt's stable +executable against the Homebrew prefix, pacman owner, or shell-managed file +digests. Native mutations delegate to Homebrew or `paru`/`yay`; the shell +channel verifies SHA-256 metadata but does not claim signature authenticity. +Uninstall prints its plan and successfully unregisters the service before +package or project mutation. Project cleanup only removes digest-matching +regular files named by `.hive/ownership.yml`; config, state, completed output, +symlinks, unknown files, and agent skills remain outside the deletion boundary. + +Release builds pass through `scripts/build-release`, whose output contract is +one executable named `build/hive-`. The primary adapter is pinned +Tebako with embedded Ruby 3.4.1; `scripts/package-release` creates the stable +archive layout, canonical JSON manifest, authenticated shell-helper records, +and SHA-256 list while ignoring adjacent SPDX artifacts. CI adds SPDX SBOMs and +GitHub build provenance for the actual release archives before publication, so +the packager can be replaced without changing channel inputs. + +The shell channel downloads the canonical manifest, target archive, installer, +and uninstaller with bounded timeouts/retries. It verifies size and SHA-256 +before extraction, smoke-checks the embedded version, stages every managed +file beside its destination, and commits them as one rollback-capable +transaction. It links `hive` only when no foreign command would be shadowed. +The receipt and verified helpers live under the XDG data directory; uninstall +removes only receipt-owned command files. + +Homebrew and AUR metadata are generated from that same manifest by +`scripts/render-packages`. Both install the prebuilt `hv` executable behind a +small receipt-exporting wrapper and carry no Ruby dependency. A conditional +`hive` link is created only when the host has no foreign command, and the +receipt records the command actually selected. Publication downloads every +public asset and verifies its size and digest before writing into separately +checked-out tap/AUR repositories. + +Agent integration is a separate `hive-skills` release tree. One canonical +`skills/hive/SKILL.md` is copied byte-for-byte into the installable plugin and +checked for drift. Claude uses its `.claude-plugin` marketplace, Codex uses its +`.agents/plugins` marketplace plus `.codex-plugin` manifest, and Pi consumes +the root `package.json#pi.skills` package. Tagged qualification installs the +current Claude, Codex, and Pi clients and performs their native marketplace or +package install flows. A compatible tag is published to the separately +versioned `ivankuznetsov/hive-skills` repository before the core release. The +core installer and `hive init` never write agent directories. + +Release qualification is deliberately gated. Tagged builds run Ruby, +distribution, lint, all three current-client skill validations, build both +tier-1 artifacts, attest the archives, and execute shell/macOS Homebrew/Arch +AUR lifecycle gates. The GitHub Release is created only after all gates and +skills publication succeed. Tap and AUR publication is a required dependent +job that re-downloads and verifies the public bytes before rendering or pushing +either downstream repository; missing channel configuration fails the tag. + ## Layer cake ``` diff --git a/wiki/cli.md b/wiki/cli.md index 179a4998..a36a7a30 100644 --- a/wiki/cli.md +++ b/wiki/cli.md @@ -21,7 +21,7 @@ tags: [cli, api] | `hive new PROJECT TEXT...` | Create a task in `1-inbox/` of a registered project | `Hive::Commands::New` | [[commands/new]] | | `hive status [--diagnose SLUG [--write [--force]] [--project NAME] [--stage STAGE]]` | Action-grouped task list across registered projects. With `--diagnose `, prints the bounded diagnostic for one task (schema `hive-status-diagnose`). Add `--write` to spawn the configured execute `AgentProfile` and atomically write `/diagnostics/red-status.md` (no lock, no marker mutation; `--force` bypasses the `marker_signature` idempotency short-circuit; green rows are rejected). | `Hive::Commands::Status` (delegates write path to `Hive::DiagnosisAgent`) | [[commands/status]] | | `hive tui` | Live, keystroke-driven Charm bubbletea + lipgloss dashboard over `hive status` (human-only; rejects `--json`) | `Hive::Tui` | [[commands/tui]] | -| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Subcommands: `start` / `stop` / `status` / `reload` / `tail` for the daemon process; `enable PROJECT \| --all` and `disable` for per-project enrollment (atomic write to `/.hive-state/config.yml`). Polls `hive status --json` and fires workflow verbs on tasks ready to advance; auto-archives 7-finalize after PR merge. New projects opt in at `hive init` (default Y); existing ones via `hive daemon enable`. See [[operating]] for the install + systemd/launchd setup guide. | `Hive::Commands::Daemon` | [[commands/daemon]] | +| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Subcommands: `start` / `stop` / `status` / `reload` / `tail` for the daemon process; `enable PROJECT \| --all` and `disable` for per-project enrollment (atomic write to `/.hive-state/config.yml`). Polls `hive status --json` and fires workflow verbs on tasks ready to advance; auto-archives 7-finalize after PR merge. Interactive init asks separately (default Y); non-TTY init leaves it disabled. Existing projects use `hive daemon enable`. See [[operating]] for the install + systemd/launchd setup guide. | `Hive::Commands::Daemon` | [[commands/daemon]] | | `hive bot SUBCOMMAND` | Telegram bot lifecycle. Subcommands: `start` / `stop` / `status` / `reload` / `tail`. Long-polls Telegram, authenticates by `bot.chat_id_allowlist`, notifies on waiting/recovery gates, and dispatches existing `hive` commands from inline buttons. | `Hive::Commands::Bot` | [[commands/bot]] | | `hive brainstorm TARGET [--from STAGE]` | Start or re-run brainstorm by slug/path | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] | | `hive plan TARGET [--from STAGE]` | Promote completed brainstorm to plan, or re-run plan | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] | @@ -39,7 +39,7 @@ tags: [cli, api] | `hive markers clear FOLDER --name [--project NAME] [--json]` | Remove a recovery marker (`REVIEW_STALE`, `REVIEW_CI_STALE`, `REVIEW_ERROR`, `EXECUTE_STALE`, `ERROR`) from a task's state file (atomic write + hive_commit). Terminal-success markers (`REVIEW_COMPLETE` / `EXECUTE_COMPLETE` / `COMPLETE`) are deliberately rejected — use `hive approve` instead. | `Hive::Commands::Markers` | [[commands/markers]] | | `hive metrics SUBCOMMAND [--days N] [--project NAME] [--json]` | Compute project-wide metrics. Currently one subcommand: `rollback-rate` walks `git log --all` and reports the fraction of fix-commits (those carrying `Hive-Fix-Pass` trailer) that were later reverted, broken down by `Hive-Triage-Bias` and `Hive-Fix-Phase`. | `Hive::Commands::Metrics` → `Hive::Metrics` | — | | `hive doctor [--json]` | Walk `brainstorm` + `plan` stage configs and every `review.reviewers[]` entry, asking each agent profile to verify its configured skill resolves to an installed slash-command / SKILL.md on disk. When `brainstorm.runtime == "tmux_interactive"` also adds a `kind: "dependency"` row checking tmux availability + minimum version. Also runs non-fatally at the tail of `hive init`. Exits 0 / 65 / 78. | `Hive::Commands::Doctor` | [[commands/doctor]] | -| `hive forget NAME [--json]` | Drop one named entry from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] | +| `hive forget NAME [--json]` | Drop one named entry from the XDG global registry (`~/.config/hive/config.yml` by default). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] | | `hive prune [--dry-run] [--json]` | Drop every registry entry whose `path` is no longer a directory on disk OR whose row shape is invalid (hand-edit accident). `--dry-run` returns the would-be-removed list without writing. | `Hive::Commands::Prune` | [[commands/prune]] | | `hive version` / `hive --version` | Print `Hive::VERSION` and exit 0. Used by e2e environment snapshots and binary smoke tests. | `Hive::CLI#version` | — | @@ -59,7 +59,7 @@ tags: [cli, api] |------|----------|---------|-----------| | 0 | `SUCCESS` | command completed | — | | 1 | `GENERIC` | unclassified `Hive::Error` | base `Hive::Error` | -| 2 | `ALREADY_INITIALIZED` | idempotent reject of `hive init` on existing project | `Hive::AlreadyInitialized` | +| 2 | `ALREADY_INITIALIZED` | reserved legacy code; current init repairs existing projects | `Hive::AlreadyInitialized` | | 3 | `TASK_IN_ERROR` | a stage agent recorded `:error` (runner itself succeeded) | `Hive::TaskInErrorState` | | 4 | `WRONG_STAGE` | `hive run` invoked on an inert stage (e.g. `1-inbox`) | `Hive::WrongStage` | | 64 | `USAGE` | EX_USAGE — bad slug, malformed task path | `Hive::InvalidTaskPath` | @@ -93,7 +93,7 @@ The CLI itself has no auth. Preconditions checked at runtime by individual stage | `Hive::StageError` | `Commands::Run#pick_runner` for unknown stage names | | `Hive::TaskInErrorState` | `Commands::Run#report` when the stage marker is `:error` | | `Hive::WrongStage` | `Stages::Inbox#run!` (running an agent on an inert stage) | -| `Hive::AlreadyInitialized` | `Commands::Init#call` when `hive/state` branch already exists | +| `Hive::AlreadyInitialized` | legacy typed error retained for exit-code compatibility; current init uses its repair path | A few stage runners still call `warn`/`exit N` directly for non-bug user errors that don't yet have a typed class — most notably `Init#validate_git_repo!` / `validate_clean_tree!` (exit 1), `Execute#run!` for `plan.md missing` (exit 1), and the `OpenPr` / `Finalize` network/auth abort paths. Migrating these to typed exceptions is tracked as Phase 2 follow-up work. diff --git a/wiki/commands/bot.md b/wiki/commands/bot.md index 0b360ad2..f0afcc76 100644 --- a/wiki/commands/bot.md +++ b/wiki/commands/bot.md @@ -3,7 +3,7 @@ title: hive bot type: command source: lib/hive/commands/bot.rb, lib/hive/bot/* created: 2026-05-14 -updated: 2026-05-14 +updated: 2026-07-24 tags: [command, bot, telegram, mobile, json] --- @@ -25,11 +25,11 @@ hive bot tail | Subcommand | Behavior | |-----------|----------| -| `start` | Loads `bot:` from `~/Dev/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes `~/Dev/hive/.bot.pid`, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. | +| `start` | Loads `bot:` from `$XDG_CONFIG_HOME/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes its XDG runtime PID file, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. | | `stop` | Sends `SIGTERM` to the PID file's process and waits up to `bot.shutdown_grace_sec`, then escalates to `SIGKILL`. Idempotent when no bot is running. With `--json`, emits `hive-bot-stop.v1`. | | `status` | Reports running/not-running and exits `0` when running, `1` when not. With `--json`, emits `hive-bot-status.v1` with `running`, `pid`, `uptime_sec`, `pid_file`, and `log_file`. | | `reload` | Sends `SIGHUP`; the supervisor reloads config at the next loop boundary while preserving in-flight children and conversations. With `--json`, emits `hive-bot-reload.v1`. | -| `tail` | Streams `~/Dev/hive/logs/bot.log`; exits 1 if the log does not exist. | +| `tail` | Streams `$XDG_STATE_HOME/hive/logs/bot.log`; exits 1 if the log does not exist. | ## Commands in Telegram @@ -67,9 +67,9 @@ bot: poll_interval_sec: 30 long_poll_timeout_sec: 25 notification_dedupe_window_sec: 300 - pid_file: ~/Dev/hive/.bot.pid - log_file: ~/Dev/hive/logs/bot.log - last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id + pid_file: ~/.local/state/hive/run/bot.pid + log_file: ~/.local/state/hive/logs/bot.log + last_seen_state_file: ~/.local/state/hive/bot.last_seen_update_id ``` `HIVE_TELEGRAM_BOT_TOKEN` is the only supported token source. Missing @@ -79,7 +79,7 @@ silently. ## Structured log -`~/Dev/hive/logs/bot.log` is one JSON document per line with schema +`$XDG_STATE_HOME/hive/logs/bot.log` is one JSON document per line with schema `hive-bot-log.v1`. The event enum is closed in `Hive::Bot::Logger::EVENTS`; unknown events raise at the call site. Events include `bot_started`, `poll_failure`, `update_received`, diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md index 308f04ce..d68fc5fa 100644 --- a/wiki/commands/daemon.md +++ b/wiki/commands/daemon.md @@ -3,7 +3,7 @@ title: hive daemon type: command source: lib/hive/commands/daemon.rb, lib/hive/daemon/* created: 2026-05-06 -updated: 2026-05-15 +updated: 2026-07-24 tags: [command, daemon, automation, json] --- @@ -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 XDG runtime PID file (`$XDG_RUNTIME_DIR/hive/daemon.pid`, falling back to `$XDG_STATE_HOME/hive/run/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. | | `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_STATE_HOME/hive/logs/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. | @@ -70,18 +70,18 @@ Each project's `.hive-state/config.yml` carries: ```yaml daemon: - enabled: true # asked at `hive init`, default Y + enabled: true # explicit TTY consent; non-TTY init writes false ``` -Newly-init'd projects render `daemon.enabled: true` from the init -prompt's default. **Legacy projects** whose YAML predates the `daemon:` +Interactive init defaults its separate service consent prompt to yes; non-TTY +init renders `daemon.enabled: false`. **Legacy projects** whose YAML predates the `daemon:` key fall back to `Config::DEFAULTS["daemon"]["enabled"] = false` — same "don't silently flip legacy projects" pattern ADR-023 used for stage agents. Operators of legacy projects opt in by adding `daemon: { enabled: true }` and running `hive daemon reload`. ## Concurrency caps -All under `daemon:` in `~/Dev/hive/config.yml`: +All under `daemon:` in `$XDG_CONFIG_HOME/hive/config.yml`: | Key | Default | Purpose | |-----|---------|---------| @@ -93,7 +93,7 @@ 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_file` | `$XDG_STATE_HOME/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 +111,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`: ## Structured log -`~/Dev/hive/logs/daemon.log` is one JSON document per line: +`$XDG_STATE_HOME/hive/logs/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/commands/forget.md b/wiki/commands/forget.md index 460118f0..400ba056 100644 --- a/wiki/commands/forget.md +++ b/wiki/commands/forget.md @@ -7,7 +7,7 @@ updated: 2026-05-06 tags: [command, registry, cleanup, json] --- -**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches NAME from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is **not** touched — registry and on-disk state are intentionally independent. Unknown name is a USAGE error (exit 64); empty NAME positional is a distinct USAGE error with `error_kind: "missing_name"`. Bulk version: [[commands/prune]]. +**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches NAME from the XDG global registry (`~/.config/hive/config.yml` by default). Inverse of `hive init`. The project's `.hive-state` directory on disk is **not** touched — registry and on-disk state are intentionally independent. Unknown name is a USAGE error (exit 64); empty NAME positional is a distinct USAGE error with `error_kind: "missing_name"`. Bulk version: [[commands/prune]]. ## Usage diff --git a/wiki/commands/init.md b/wiki/commands/init.md index ee73c372..b390d8da 100644 --- a/wiki/commands/init.md +++ b/wiki/commands/init.md @@ -3,11 +3,15 @@ title: hive init type: command source: lib/hive/commands/init.rb created: 2026-04-25 -updated: 2026-05-04 +updated: 2026-07-24 tags: [command, bootstrap, git, prompts] --- -**TLDR**: `hive init [PATH]` bootstraps a project for hive: creates the orphan `hive/state` branch, attaches it as a worktree at `/.hive-state/`, scaffolds stage folders, asks the operator (on TTY) which agents to use for planning / development / review and what budget+timeout sanity caps to set, scaffolds `config.yml` from those answers, ignores `.hive-state/` in master, and registers the project globally. +**TLDR**: `hive init [PATH]` bootstraps or repairs a project: it checks +dependencies, creates/attaches the `hive/state` worktree, collects project, +provider/model, credential-reference, and separate daemon-start choices, +scaffolds from the immutable XDG release, preserves existing global config, +registers the user service, and registers the project globally. ## Usage @@ -22,12 +26,18 @@ hive init [PROJECT_PATH] [--force] 1. Path must be a git repository — `git -C rev-parse --git-common-dir` must succeed. 2. Path must be the **main checkout**, not a worktree. The check compares git's common-dir against `/.git`. Running inside an existing worktree exits 1 with `"target appears to be inside a worktree"`. 3. Working tree must be clean unless `--force` is passed; otherwise exit 1 with `"uncommitted changes"`. -4. `hive/state` branch must not already exist; if it does, prints `"already initialized"` and exits 2 (idempotent). +4. A pre-existing `hive/state` branch is accepted. Init enters its repair path + without prompting, reattaches/reseeds missing managed state, and preserves + existing configuration and user-edited scaffold files. ## Steps performed -1. **Validate** — `validate_git_repo!` then `validate_clean_tree!` (skipped under `--force`). -2. **Already-initialized guard** — if `hive/state` exists, raise `Hive::AlreadyInitialized` (exit 2). Runs **before** the prompt so a re-run never asks the user anything. +1. **Diagnose dependencies, then validate** — missing `git`, `bash`, `claude`, + `gh`, or `jq` gets an actionable channel-independent warning before any + Git-dependent operation. Init then runs `validate_git_repo!` and the + clean-tree check (skipped under `--force`). +2. **Repair guard** — if `hive/state` exists, run the non-interactive repair + path before any prompt. 3. **Collect prompt answers** — `Hive::Commands::Init::Prompts.new(input: $stdin, output: $stderr, summary_io: $stdout).collect`. Prompt UI (intro / menus / re-prompts / confirmation) goes to **stderr**; the non-TTY one-line summary goes to **stdout** so scripted callers can `summary=$(hive init)` cleanly. On TTY this opens the interactive flow described below; on non-TTY (CI, pipes, test harness) it short-circuits to recommended defaults. Two abort paths exit **64** (`Hive::ExitCodes::USAGE`, distinct from generic crashes at 1) with **zero disk side effects** — no orphan branch, no worktree, no master gitignore commit: - Operator answers `n` at the final confirmation prompt. - Input stream closes mid-flow (Ctrl-D / EOF / disconnected pipe). `Prompts#read_line` distinguishes `nil` (closed stream) from `""` (blank line for default) and raises `Aborted`. Treating EOF as a blank confirmation would silently write disk state with whatever was already collected. @@ -37,19 +47,31 @@ hive init [PROJECT_PATH] [--force] - `git rm -rf .` plus glob cleanup of any leftover dotfiles (preserving `.git`). - Create `stages/{1-inbox,2-brainstorm,3-plan,4-execute,7-finalize,8-done}/` with `.gitkeep` markers and `logs/.gitkeep`. - Initial commit `hive: bootstrap` on `hive/state`. -5. **Render `/.hive-state/config.yml`** from `templates/project_config.yml.erb`, threading the answers hash from step 3 through `ProjectConfigBinding`. Skipped if the file already exists. -6. **Ignore `.hive-state/` on master** via `GitOps#add_hive_state_to_master_gitignore!`: appends `/.hive-state/` to `.gitignore` (idempotent), then commits `chore: ignore .hive-state worktree` on master. -7. **Register globally** via `Hive::Config.register_project(name: basename(path), path: path)`, writing into `~/Dev/hive/config.yml`. -8. Print summary: project name, default branch, hive-state path, worktree root, and a `next:` line with the `hive new ...` invocation. +5. **Render project and provider config** — write the project YAML from prompt + answers; merge provider/model/credential-environment fields into + `$XDG_CONFIG_HOME/hive/config.yml` without replacing unrelated keys. +6. **Seed and scaffold** — materialize + `$XDG_DATA_HOME/hive/releases/`, repair `current`, and scaffold + `.hive/` from that release with digest and symlink confinement. +7. **Register the service** — write the user-service definition stopped by + default; start it only after explicit TTY consent. Non-TTY init never starts + it. +8. **Ignore and register** — idempotently ignore `.hive-state/` on master and + register the project in the XDG global config. +9. Print the summary and run the non-fatal doctor preflight. ## Prompt flow (ADR-023) -On TTY input streams the prompt walks the operator through four sections in order: +On TTY input streams the prompt walks the operator through these choices in order: 1. **Planning agent** (`brainstorm.agent` + `plan.agent`): one combined choice; the answer maps to both keys. Recommended default `claude`. 2. **Development agent** (`execute.agent`): the implementer in `4-execute`. Recommended default `codex` (its edit-mode is more efficient for implementation work). Codex's status-detection mode is `:output_file_exists`, but the execute spawn pins `status_mode: :state_file_marker` because the stage's lifecycle contract is the marker the agent writes — the pin keeps that contract independent of the chosen profile. 3. **Review agents** (`review.reviewers[]`): multi-select over the three default reviewers (claude-ce-code-review, codex-ce-code-review, pr-review-toolkit). Disabled entries are omitted from the rendered array. -4. **Per-stage limits**: budget+timeout for each of 8 effective keys (`brainstorm`, `plan`, `execute_implementation`, `pr`, `review_ci`, `review_triage`, `review_fix`, `review_browser`). Defaults are generous sanity caps — most tasks finish well within them. +4. **Triage bias and per-stage limits**: budget+timeout for the current + pipeline keys. +5. **Daemon start consent**: separate from all install/init consent. +6. **Provider, model, and credential environment variable**: stores only the + environment-variable name, never a credential value. Each agent and reviewer prompt accepts **either a name or a 1-based index** (e.g., `codex` or `2`; `claude-ce-code-review,pr-review-toolkit` or `1,3`). Name strings are the recommended path for scripted automation since they're stable across template-default reordering. @@ -66,7 +88,7 @@ Reordering either is a **breaking change for scripted automation** that uses ind When `$stdin.tty?` is false the prompt module skips every question and emits exactly one line to `$stdout`: ``` -hive: using defaults — planning=claude, dev=codex, reviewers=all3, limits=defaults +hive: using defaults — planning=claude, dev=codex, reviewers=all3, triage=courageous, limits=defaults, daemon=disabled, provider=openai, model=gpt-5 ``` Piped input is **not** consumed — `printf 'codex\n...' | hive init` ignores the piped data and uses defaults. Document this contract for any automation that wants to set non-default values: use `--force` plus an explicitly-edited YAML rather than expecting heredoc piping to populate answers. @@ -89,12 +111,16 @@ This branch is what the orphan worktree is initially based on, and what feature | `not a git repository` | path isn't a git repo | `git init` first | | `target appears to be inside a worktree` | running from a feature worktree | run on main checkout | | `uncommitted changes` | dirty working tree | commit/stash, or pass `--force` | -| `already initialized` (exit 2) | `hive/state` already exists | nothing to do | +| repair fails | existing `hive/state` cannot be safely reattached or scaffolded | resolve the reported Git/path/service error and rerun init | ## Tests -- `test/integration/init_test.rb` covers all five preconditions, the `--force` path, the idempotent double-init, the rendered template's stage-agent blocks, the bumped-generous limits, the dropped `execute_review` key, and the U5 piped-input + abort + already-initialized-guard scenarios. -- `test/unit/commands/init/prompts_test.rb` covers the prompt module in isolation: 29 cases over happy paths, edge re-prompts, the non-TTY summary contract, and the testability invariant. +- `test/integration/init_test.rb` covers preconditions, `--force`, idempotent + repair, first-run provider persistence, daemon consent, rendered defaults, + piped input, and zero-footprint abort. +- `test/unit/commands/init/prompts_test.rb` covers prompt happy paths, + validation/re-prompts, provider/model choices, separate service consent, and + the non-TTY contract. ## Backlinks diff --git a/wiki/commands/prune.md b/wiki/commands/prune.md index 531131be..4fecb806 100644 --- a/wiki/commands/prune.md +++ b/wiki/commands/prune.md @@ -7,7 +7,7 @@ updated: 2026-05-06 tags: [command, registry, cleanup, json] --- -**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in `~/Dev/hive/config.yml` whose `path` no longer points at a directory on disk **or** whose row shape is invalid (non-Hash, missing `path`, non-String name/path — all hand-edit accidents). The project's `.hive-state` directory on disk (when present) is **not** touched. Bulk version of [[commands/forget]] for the common-case `mktemp -d`-style stale-entry pile-up. +**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in the XDG global config (`~/.config/hive/config.yml` by default) whose `path` no longer points at a directory on disk **or** whose row shape is invalid (non-Hash, missing `path`, non-String name/path — all hand-edit accidents). The project's `.hive-state` directory on disk (when present) is **not** touched. Bulk version of [[commands/forget]] for the common-case `mktemp -d`-style stale-entry pile-up. ## Usage diff --git a/wiki/log.md b/wiki/log.md index 641bec70..55592913 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1607,3 +1607,47 @@ dispatch while preserving first-sight brainstorm baseline behavior. - `wiki/commands/tui.md` — documented red-status detail mode, keybindings, snapshot refresh behavior, and preserved direct recovery exceptions. - `wiki/decisions.md` — ADR-027 records the diagnose-then-act policy and its relationship to ADR-025. - `wiki/index.md` — bumped refresh date. +## [2026-07-24] Distribution U1 — packaged path and command contract + +Added a relocatable entrypoint, centralized Hive version, XDG config/data/state/cache/runtime resolution, bundled-resource lookup, and collision-aware `hive`/`hv` command selection. Packaged-entrypoint coverage now runs from outside the checkout. See [[architecture]] and [[cli]]. +## [2026-07-24] Distribution U2 — safe first-run assets + +Extended `hive init` with versioned shared assets, digest-owned `.hive/` scaffolding, optional provider/model/credential-reference configuration, and non-mutating dependency diagnostics. Existing and modified project content is preserved, raw credential values are rejected, and no agent-owned skill directory is written. See [[architecture]] and [[commands/init]]. +## [2026-07-24] Distribution U3 — native user services + +Added common register/start/stop/status/unregister behavior with launchd and systemd-user adapters. Definitions use the stable installed executable and XDG-aware state paths; re-registration updates one service definition. `hive init` registers after setup and starts only from the existing daemon-consent choice. See [[architecture]] and [[commands/daemon]]. +## [2026-07-24] Distribution U4 — channel receipts and safe removal + +Added versioned install receipts, ownership checks, native update/removal delegation, service unregister, and digest-gated project cleanup. Ambiguous/malformed ownership fails with repair instructions; default and purge paths preserve XDG state/config, completed work, unknown files, symlinks, and skills. See [[architecture]] and [[cli]]. +## [2026-07-24] Distribution U5 — self-contained release contract + +Added the pinned Tebako builder boundary, tier-1 build workflow, stable archive layout, canonical release-manifest schema/generator, checksums, SPDX generation, and build provenance. Strict semver and target validation stop publication inputs early. See [[architecture]] and [[dependencies]]. +## [2026-07-24] Distribution U6 — verified shell channel + +Added a non-root tier-1 installer with manifest selection, mandatory SHA-256 and embedded-version checks, atomic command replacement, collision-safe `hv`, PATH/dependency diagnostics, a shell receipt, updates, and a narrowly scoped self-uninstaller. Failure before replacement leaves the previous executable intact. See [[architecture]] and [[cli]]. +## [2026-07-24] Distribution U7 — native package generation + +Added manifest-driven Homebrew and `hive-bin` AUR generation, collision-aware `hive` linking with guaranteed `hv`, native receipts/wrappers, package lifecycle hooks, and a two-repository publisher that verifies release assets before copying metadata. Package installation never starts the daemon and declares no Ruby dependency. See [[architecture]] and [[dependencies]]. +## [2026-07-24] Distribution U8 — agent prompt and skills package + +Added one consent-gated host-neutral install prompt and an exportable `hive-skills` repository tree with a canonical skill plus Claude, Codex, and Pi adapters. Current-client validation established native commands for all three: Claude marketplace/plugin install, Codex marketplace/plugin add, and Pi Git package install. Claude's native marketplace validator passes; canonical/package skill drift is contract-tested. See [[architecture]] and [[commands/doctor]]. +## [2026-07-24] Distribution U9 — release qualification and gates + +Added the two-phase tagged release workflow, cross-platform install-smoke jobs, canonical release verifier, distribution Rake suite, AE1–AE4 safety contracts, and install/release/uninstall documentation. Release reruns reject existing tags/releases; downstream publishing runs only after public asset verification. See [[architecture]], [[testing]], and [[cli]]. + +## [2026-07-24] Testing — portable background subprocess cleanup + +The concurrent TUI subprocess integration fixture now reports its own child PIDs through a test-only file instead of discovering them with `pgrep`. This preserves the process/reaper coverage on minimal tier-1 release hosts without adding procps as a test dependency. See [[testing]]. + +## [2026-07-24] Distribution review — ownership and publication hardening + +Closed the first distribution review pass across installer transactions, +versioned receipts, XDG runtime paths, immutable scaffold assets, service +rollback, and idempotent first-run configuration. Release qualification now +authenticates shell helpers, attests the released archives, runs current +Claude/Codex/Pi native-install checks plus shell/Homebrew/AUR lifecycle gates, +publishes the compatible skills tag, and verifies downloaded public bytes +before both downstream channels. The distribution suite and portable +background-process PID-file contract are documented in [[testing]]; runtime +and channel invariants are reflected in [[architecture]], [[commands/init]], +[[commands/daemon]], and [[operating]]. diff --git a/wiki/modules/bot.md b/wiki/modules/bot.md index c7df393a..59319ada 100644 --- a/wiki/modules/bot.md +++ b/wiki/modules/bot.md @@ -38,7 +38,7 @@ and subprocess I/O. hive bot start └─ Hive::Commands::Bot ├─ validates global bot config + HIVE_TELEGRAM_BOT_TOKEN - ├─ writes ~/Dev/hive/.bot.pid + ├─ writes the XDG runtime bot PID file └─ Hive::Bot::Supervisor.run_forever ├─ poll loop: Telegram.getUpdates → Router → handler descriptors ├─ status loop: hive status --json → NotificationDispatcher diff --git a/wiki/modules/config.md b/wiki/modules/config.md index b7c53d6f..c3556ea2 100644 --- a/wiki/modules/config.md +++ b/wiki/modules/config.md @@ -7,7 +7,11 @@ updated: 2026-05-14 tags: [config, yaml, validation] --- -**TLDR**: Two YAML configs — global at `~/Dev/hive/config.yml` (registered projects) and per-project at `/.hive-state/config.yml` (default branch, worktree root, budgets, timeouts, **stage agents**, review-stage roles). `Config.load(project_root)` **recursively** deep-merges per-project values onto `Config::DEFAULTS`, then runs `validate!`. Arrays (notably `review.reviewers`) are replaced wholesale, never per-element merged. +**TLDR**: Two YAML configs — global at `$XDG_CONFIG_HOME/hive/config.yml` +(registered projects plus provider/daemon/bot settings) and per-project at +`/.hive-state/config.yml`. `Config.load(project_root)` recursively +deep-merges project values onto `Config::DEFAULTS`; arrays are replaced +wholesale. ## Defaults (`Config::DEFAULTS`) @@ -68,7 +72,7 @@ tags: [config, yaml, validation] | Function | Returns / does | |----------|----------------| -| `hive_home` | `ENV["HIVE_HOME"] || ~/Dev/hive` | +| `hive_home` | legacy `ENV["HIVE_HOME"]`, otherwise `Hive::Paths.config_dir` | | `global_config_path` | `/config.yml` | | `hive_state_dir(project_root, name = ".hive-state")` | `/` | | `load(project_root)` | Reads `/.hive-state/config.yml`, recursively deep-merges onto DEFAULTS, validates, returns Hash with `"project_root"` injected. Returns DEFAULTS-only hash if config absent. | diff --git a/wiki/modules/daemon.md b/wiki/modules/daemon.md index 81498482..fa8f1d65 100644 --- a/wiki/modules/daemon.md +++ b/wiki/modules/daemon.md @@ -31,9 +31,9 @@ decisions are unit-testable without forking. ``` hive daemon start └─ Hive::Commands::Daemon - ├─ writes ~/Dev/hive/.daemon.pid + ├─ writes the XDG runtime daemon PID file └─ Hive::Daemon::Dispatcher.run_forever - ├─ Hive::Daemon::Logger (~/Dev/hive/logs/daemon.log, JSON-line) + ├─ Hive::Daemon::Logger ($XDG_STATE_HOME/hive/logs/daemon.log, JSON-line) ├─ Hive::Daemon::ConcurrencyController ├─ Hive::Daemon::ChildSupervisor (Process.spawn pgroup: true) ├─ Hive::Daemon::StatusConsumer (Open3.capture3 hive status --json) diff --git a/wiki/operating.md b/wiki/operating.md index bf12c796..1feb509b 100644 --- a/wiki/operating.md +++ b/wiki/operating.md @@ -3,7 +3,7 @@ title: Operating Hive type: operating source: lib/hive/commands/daemon.rb, lib/hive/commands/bot.rb, examples/systemd/, examples/launchd/ created: 2026-05-07 -updated: 2026-05-14 +updated: 2026-07-24 tags: [operating, daemon, bot, systemd, launchd, install] --- @@ -27,7 +27,8 @@ Once per workstation: Verify: `ls /proc/$$ >/dev/null && echo OK` or `command -v ps`. - **Telegram bot token** if using `hive bot`. Create the bot with BotFather, export `HIVE_TELEGRAM_BOT_TOKEN`, and add your numeric - `chat_id` to `bot.chat_id_allowlist` in `~/Dev/hive/config.yml`. + `chat_id` to `bot.chat_id_allowlist` in + `$XDG_CONFIG_HOME/hive/config.yml`. ## Enrolling existing projects @@ -54,8 +55,9 @@ The command edits the project's `.hive-state/config.yml` atomically tunables (`poll_interval_sec`, `max_concurrent_runs`, …) survive — the toggle only flips `enabled`. -For new projects, `hive init` asks at the TTY prompt and defaults to Y -— no separate enable step. +For new projects, interactive `hive init` asks separately and defaults to Y. +Non-TTY init writes `daemon.enabled: false`; enable it explicitly after +reviewing the generated configuration. ## First run: the mandatory `--dry-run` shakedown @@ -70,21 +72,21 @@ hive daemon status # verify running hive daemon tail # ctrl-C to leave; daemon keeps running ``` -Inspect `~/Dev/hive/logs/daemon.log` (one JSON document per line): +Inspect `$XDG_STATE_HOME/hive/logs/daemon.log` (one JSON document per line): ```bash # What WOULD have been dispatched, by project jq -r 'select(.event=="dispatched") | "\(.project)/\(.slug): \(.command)"' \ - ~/Dev/hive/logs/daemon.log | sort -u + "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u # Things skipped — the `reason` field tells you why jq -r 'select(.event=="skipped") | "\(.project)/\(.slug): action=\(.action) reason=\(.reason // "—")"' \ - ~/Dev/hive/logs/daemon.log | sort -u + "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u # Anything blocked by caps (means at least one cap is too tight, or # expected throttling under load) jq -r 'select(.event=="blocked") | "\(.project)/\(.slug): \(.reason)"' \ - ~/Dev/hive/logs/daemon.log | sort -u + "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u ``` Once the would-be dispatches look right, swap to live mode: @@ -151,7 +153,7 @@ launchctl load ~/Library/LaunchAgents/hive-daemon.plist launchd captures stdout/stderr to the paths declared in the plist (`~/Library/Logs/hive-daemon.{out,err}.log` by default). The daemon's -own structured log (`~/Dev/hive/logs/daemon.log`) is independent and +own structured log (`$XDG_STATE_HOME/hive/logs/daemon.log`) is independent and preferred for parsing — the launchd capture is mostly empty. `KeepAlive` with `SuccessfulExit: false` means launchd respawns on @@ -170,7 +172,8 @@ normally. If you customise `ProgramArguments`, keep the precheck. ## Bot setup -The bot is global and uses the registry in `~/Dev/hive/config.yml`. +The bot is global and uses the registry in +`$XDG_CONFIG_HOME/hive/config.yml`. Minimum config: ```yaml @@ -188,10 +191,10 @@ hive bot start --dry-run `--dry-run` is useful for first shakedown: inbound command parsing and status notifications run, but state-changing child commands are not -spawned. The bot log is `~/Dev/hive/logs/bot.log`: +spawned. The bot log is `$XDG_STATE_HOME/hive/logs/bot.log`: ```bash -jq -r '.event' ~/Dev/hive/logs/bot.log | sort | uniq -c +jq -r '.event' "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/bot.log" | sort | uniq -c ``` Unauthorized chat IDs are logged once and receive no reply. Missing @@ -229,14 +232,14 @@ drain path when you are not using systemd/launchd. |-----------------------------------------------|-------------------------------------------------------| | Status + uptime | `hive daemon status` (`--json` for envelope) | | Follow the structured log | `hive daemon tail` | -| Reload caps without restart | edit `~/Dev/hive/config.yml` → `hive daemon reload` | +| Reload caps without restart | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive daemon reload` | | Disable a project mid-flight | `hive daemon disable PROJECT` → `hive daemon reload`* | | Enable a project mid-flight | `hive daemon enable PROJECT` → `hive daemon reload`* | | Drain + stop | `hive daemon stop` (graceful TERM, ≤ `shutdown_grace_sec`) | | Force-stop after a hang | `hive daemon stop` then check PID; the stop CLI escalates to KILL | | Bot status / uptime | `hive bot status` (`--json` for envelope) | | Follow bot log | `hive bot tail` | -| Reload bot allowlist / polling config | edit `~/Dev/hive/config.yml` → `hive bot reload` | +| Reload bot allowlist / polling config | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive bot reload` | | Drain + stop bot | `hive bot stop` | \* `hive daemon reload` clears the per-tick enable cache, but the @@ -271,7 +274,7 @@ Defaults in `Config::DEFAULTS["daemon"]`: | `log_max_bytes` | 10 MB | Rotation threshold. | | `log_max_files` | 5 | 5 × 10 MB = 50 MB log budget. | -To override, edit `~/Dev/hive/config.yml`: +To override, edit `$XDG_CONFIG_HOME/hive/config.yml`: ```yaml registered_projects: @@ -308,7 +311,8 @@ images). Mount `/proc`, install `procps`, or run the daemon in an environment where one of the two is reachable. **`hive daemon status` says "not running" but I just started it.** -Check `~/Dev/hive/.daemon.pid`. If it's missing, the daemon failed +Check `$XDG_RUNTIME_DIR/hive/daemon.pid` (or the +`$XDG_STATE_HOME/hive/run/daemon.pid` fallback). If it's missing, the daemon failed during startup — check `journalctl --user -u hive-daemon` (Linux) or the launchd `~/Library/Logs/hive-daemon.err.log` (macOS) for the crash reason. diff --git a/wiki/state-model.md b/wiki/state-model.md index 29a9e1a2..4818b439 100644 --- a/wiki/state-model.md +++ b/wiki/state-model.md @@ -7,7 +7,10 @@ updated: 2026-05-14 tags: [state, filesystem, model, architecture, review] --- -**TLDR**: Hive has no database. Persistent state lives entirely in two filesystem trees per project — `/.hive-state/` (an orphan-branch worktree holding task folders, configs, locks, logs) and `~/Dev/.worktrees//` (feature worktrees holding actual code) — plus one global `~/Dev/hive/config.yml`. The "data model" is the directory layout, marker grammar, and YAML schemas described below. +**TLDR**: Hive has no database. Persistent state lives in two filesystem +trees per project—`/.hive-state/` and the configured feature-worktree +root—plus XDG-separated global config/data/state. The data model is the +directory layout, marker grammar, and YAML schemas described below. ## Stage directory layout @@ -142,7 +145,7 @@ No `pass:` frontmatter or sidecar — recovery is "delete the highest-NN files t ## Configs -### Global: `~/Dev/hive/config.yml` +### Global: `$XDG_CONFIG_HOME/hive/config.yml` ```yaml registered_projects: @@ -159,18 +162,18 @@ bot: codex_budget_usd: 1 codex_timeout_sec: 120 shutdown_grace_sec: 60 - pid_file: ~/Dev/hive/.bot.pid - log_file: ~/Dev/hive/logs/bot.log + pid_file: ~/.local/state/hive/run/bot.pid + log_file: ~/.local/state/hive/logs/bot.log log_max_bytes: 10485760 log_max_files: 5 - last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id + last_seen_state_file: ~/.local/state/hive/bot.last_seen_update_id ``` -Managed by `Hive::Config.register_project` (`lib/hive/config.rb:79`); deregistered by `unregister_project` (one row, by name) and `prune_missing_projects!` (every row whose `path` is missing OR whose shape is invalid). `HIVE_HOME` env var overrides the default `~/Dev/hive`. +Managed by `Hive::Config.register_project` (`lib/hive/config.rb`); deregistered by `unregister_project` (one row, by name) and `prune_missing_projects!` (every row whose `path` is missing OR whose shape is invalid). The default is `~/.config/hive`; `HIVE_HOME` remains a legacy override. -Loader tolerance (`Config.registered_projects` / `load_global_config`): a non-Hash row, a row missing `name`, or a row whose `path` isn't a String is *skipped silently* instead of raising — a single hand-edit accident can no longer brick `status`/`forget`/`prune`/TUI. `Psych::Exception` (any malformed YAML — syntax, disallowed-class, alias-not-enabled) plus `Errno::EACCES`/`EISDIR` are rewrapped as `ConfigError` (exit 78); `chmod 000 ~/Dev/hive/config.yml` no longer leaks as exit-70 InternalError. `prune` is the cleanup verb for invalid rows surfaced this way (predicate `Config.droppable_registry_entry?` covers both missing-path and invalid-shape; `valid_registry_entry?` is the shared shape gate). All writes go through `write_global_config!` so the read/write classification is symmetric and a future flock upgrade (Issue #31) lands in one place. See [[commands/forget]] · [[commands/prune]] · [[modules/config]]. All writers (`register_project`, `unregister_project`, `prune_missing_projects!`) go through `Hive::Config.write_global_config!`, which rewraps `Errno::EACCES`/`EROFS`/`ENOSPC` as `Hive::ConfigError` (exit 78). The reader (`load_global_config`) likewise rewraps `Psych::SyntaxError` AND `Errno::EACCES`/`EISDIR` to `ConfigError` so a `chmod 000` on `~/Dev/hive/config.yml` surfaces as exit 78, not exit 70. Name matching in `unregister_project` is `to_s`-symmetric so a hand-edited Integer `name:` in YAML still resolves. `forget`/`prune` `--json` envelopes use `Hive::Schemas::EnvelopeEmitter` (`lib/hive.rb`) and `File.expand_path` raw `path` / `hive_state_path` to honor the schemas' "Absolute path" contract regardless of how the registry row was hand-edited. +Loader tolerance (`Config.registered_projects` / `load_global_config`): a non-Hash row, a row missing `name`, or a row whose `path` isn't a String is *skipped silently* instead of raising — a single hand-edit accident can no longer brick `status`/`forget`/`prune`/TUI. `Psych::Exception` (any malformed YAML — syntax, disallowed-class, alias-not-enabled) plus `Errno::EACCES`/`EISDIR` are rewrapped as `ConfigError` (exit 78); an unreadable XDG config no longer leaks as exit-70 InternalError. `prune` is the cleanup verb for invalid rows surfaced this way (predicate `Config.droppable_registry_entry?` covers both missing-path and invalid-shape; `valid_registry_entry?` is the shared shape gate). All writes go through `write_global_config!` so the read/write classification is symmetric and a future flock upgrade (Issue #31) lands in one place. See [[commands/forget]] · [[commands/prune]] · [[modules/config]]. All writers (`register_project`, `unregister_project`, `prune_missing_projects!`) go through `Hive::Config.write_global_config!`, which rewraps `Errno::EACCES`/`EROFS`/`ENOSPC` as `Hive::ConfigError` (exit 78). Name matching in `unregister_project` is `to_s`-symmetric so a hand-edited Integer `name:` in YAML still resolves. `forget`/`prune` `--json` envelopes use `Hive::Schemas::EnvelopeEmitter` (`lib/hive.rb`) and `File.expand_path` raw `path` / `hive_state_path` to honor the schemas' "Absolute path" contract regardless of how the registry row was hand-edited. -`bot:` is a global operator-surface block, not a per-project enrollment knob. `Config.load_global_bot(require_runtime: true)` merges it over `Config.global_bot_defaults`, validates integer chat IDs, poll bounds, and path strings, then requires both a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN` before `hive bot start` can run. Runtime files are global: `~/Dev/hive/.bot.pid` for the single-instance lock, `~/Dev/hive/logs/bot.log` for structured JSON lines, and `~/Dev/hive/.bot.last_seen_update_id` for Telegram reconnect summaries. +`bot:` is a global operator-surface block, not a per-project enrollment knob. `Config.load_global_bot(require_runtime: true)` merges it over `Config.global_bot_defaults`, validates integer chat IDs, poll bounds, and path strings, then requires both a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN` before `hive bot start` can run. Runtime files are global but XDG-separated: the PID is under the runtime root, while logs and reconnect state are under the state root. ### Per-project: `/.hive-state/config.yml` diff --git a/wiki/testing.md b/wiki/testing.md index 28096a6d..e408210e 100644 --- a/wiki/testing.md +++ b/wiki/testing.md @@ -3,7 +3,7 @@ title: Testing type: reference source: test/, Rakefile, .rubocop.yml created: 2026-04-25 -updated: 2026-04-29 +updated: 2026-07-24 tags: [test, minitest, fixtures] --- @@ -30,7 +30,10 @@ task default: :test - `with_tmp_dir` — `Dir.mktmpdir("hive-test", &block)`. - `with_tmp_git_repo` — `git init -b master`, configures user/email and disables GPG signing, makes one initial commit, yields the path. -- `with_tmp_global_config` — overrides `ENV["HIVE_HOME"]` to a tmp dir and writes an empty `registered_projects: []` YAML so tests don't touch `~/Dev/hive/config.yml`. +- `with_tmp_global_config` — points the legacy `HIVE_HOME` override and every + XDG root at one temporary tree, then writes an empty + `registered_projects: []` YAML. Tests therefore cannot touch the operator's + config, data, state, cache, or runtime files. - `run!(*cmd)` — shells out and raises on non-zero exit (used in setup helpers; not for testing the CLI itself). ## Fixtures @@ -80,6 +83,23 @@ bin/hive-e2e run The six starter scenarios copy `test/e2e/sample-project/` into a per-run sandbox, set `HIVE_HOME` to a run-local directory, and call the real `bin/hive` as a subprocess. TUI scenarios use private tmux sockets (`hive-e2e-`) so they never touch the operator's daily tmux server. +Background-process fixtures report child PIDs through test-only PID files. +Tests must not discover those children with `pgrep`: procps is not present on +every tier-1 release host, and process-name matching can select unrelated +processes. The PID-file handshake keeps reaper/cleanup assertions portable. + +## Distribution suite + +```bash +bundle exec rake distribution +``` + +The distribution suite is a release gate. It covers the packaged entrypoint, +release-manifest/archive contracts, shell installation and preservation, +Homebrew/AUR rendering, client-native skills adapters, and the unchanged +agent-install prompt. Tagged CI also executes the generated shell, Homebrew, +and AUR lifecycle gates before creating the GitHub Release. + ## Lint `bundle exec rubocop` is the lint command. Config in `.rubocop.yml`: