diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..d43f6752 --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,49 @@ +name: Release smoke + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + runtime-linux: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: gem install tebako -v 0.14.0 --no-document + - name: Build and execute the Tebako payload without Ruby on PATH + run: | + chmod +x scripts/* + export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + DIST_DIR=$PWD/dist scripts/build-release + mkdir extracted + tar -xzf dist/hive-linux-x86_64.tar.gz -C extracted + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \ + XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version + + runtime-macos: + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: | + gem install tebako -v 0.14.0 --no-document + brew install gnu-tar + - name: Build and execute the Tebako payload without Ruby on PATH + run: | + chmod +x scripts/* + export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + TAR=gtar DIST_DIR=$PWD/dist scripts/build-release + mkdir extracted + tar -xzf dist/hive-macos-arm64.tar.gz -C extracted + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \ + XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d6282c3f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,260 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + test: + uses: ./.github/workflows/tier1-e2e.yml + + build-linux: + name: Build Tebako linux-x86_64 + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: gem install tebako -v 0.14.0 --no-document + - name: Build deterministic artifact + run: | + chmod +x scripts/* + export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + DIST_DIR=$PWD/dist scripts/build-release + - uses: actions/upload-artifact@v4 + with: + name: dist-linux + path: dist/hive-linux-x86_64.tar.gz + + build-macos: + name: Build Tebako macos-arm64 + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: | + gem install tebako -v 0.14.0 --no-document + brew install gnu-tar + - name: Build deterministic artifact + run: | + chmod +x scripts/* + export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + TAR=gtar DIST_DIR=$PWD/dist scripts/build-release + - uses: actions/upload-artifact@v4 + with: + name: dist-macos + path: dist/hive-macos-arm64.tar.gz + + assemble: + name: Assemble and verify immutable release identity + needs: [build-linux, build-macos] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + - uses: actions/download-artifact@v4 + with: + path: downloads + - name: Generate manifest, checksums, installer, and downstream metadata + run: | + mkdir dist + cp downloads/dist-linux/hive-linux-x86_64.tar.gz dist/ + cp downloads/dist-macos/hive-macos-arm64.tar.gz dist/ + export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) + scripts/generate-release-manifest dist "${{ github.ref_name }}" "${{ github.sha }}" + scripts/verify-release dist + scripts/render-package-metadata dist/manifest.json package-metadata + - uses: actions/upload-artifact@v4 + with: + name: release-dist + path: dist/* + - uses: actions/upload-artifact@v4 + with: + name: downstream-metadata + include-hidden-files: true + path: package-metadata/** + + runtime-linux: + name: Runtime closure on Ubuntu 22.04 + needs: assemble + runs-on: ubuntu-22.04 + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - run: | + mkdir extracted + tar -xzf dist/hive-linux-x86_64.tar.gz -C extracted + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \ + XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version + + runtime-macos: + name: Runtime closure on macOS 14 arm64 + needs: assemble + runs-on: macos-14 + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - run: | + mkdir extracted + tar -xzf dist/hive-macos-arm64.tar.gz -C extracted + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \ + XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version + + install-ubuntu: + name: Clean Ubuntu Bash install lifecycle + needs: assemble + runs-on: ubuntu-22.04 + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - name: Install both aliases from the assembled release + run: | + HOME="$RUNNER_TEMP/home" XDG_DATA_HOME="$RUNNER_TEMP/data" XDG_BIN_HOME="$RUNNER_TEMP/bin" \ + bash dist/install.sh --version "${GITHUB_REF_NAME#v}" --base-url "file://$PWD/dist" --skip-provenance + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent "$RUNNER_TEMP/bin/hive" --version + env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent "$RUNNER_TEMP/bin/hv" --version + + install-macos: + name: Clean macOS Homebrew install lifecycle + needs: assemble + runs-on: macos-14 + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - uses: actions/download-artifact@v4 + with: + name: downstream-metadata + path: package-metadata + - name: Install the exact artifact through Homebrew + run: | + formula=package-metadata/homebrew-tap/Formula/hive.rb + ruby -pi -e 'sub(%r{https://github.com/ivankuznetsov/hive/releases/download/[^\"]+}, "file://#{File.expand_path("dist/hive-macos-arm64.tar.gz")}")' "$formula" + brew install --formula "$formula" + hive --version + hv --version + brew uninstall --formula hive + + install-arch: + name: Clean Arch hive-bin lifecycle + needs: assemble + runs-on: ubuntu-22.04 + container: archlinux:base + steps: + - name: Prepare clean Arch host + run: pacman -Syu --noconfirm base-devel git sudo + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - uses: actions/download-artifact@v4 + with: + name: downstream-metadata + path: package-metadata + - name: Build and install exact AUR metadata + run: | + useradd -m builder + cp -a package-metadata/aur/hive-bin /home/builder/pkg + cp dist/hive-linux-x86_64.tar.gz /home/builder/pkg/ + sed -i "s#source=(.*#source=('file://$PWD/dist/hive-linux-x86_64.tar.gz')#" /home/builder/pkg/PKGBUILD + chown -R builder:builder /home/builder/pkg + runuser -u builder -- bash -lc 'cd ~/pkg && makepkg --noconfirm' + pacman -U --noconfirm /home/builder/pkg/hive-bin-*.pkg.tar.zst + hive --version + hv --version + pacman -R --noconfirm hive-bin + + publish: + name: Publish GitHub Release + needs: [test, assemble, runtime-linux, runtime-macos, install-ubuntu, install-macos, install-arch] + runs-on: ubuntu-22.04 + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - name: Attest every published release input + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + dist/*.tar.gz + dist/install.sh + dist/manifest.json + dist/SHA256SUMS + - name: Create immutable release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: | + dist/*.tar.gz + dist/install.sh + dist/manifest.json + dist/SHA256SUMS + fail_on_unmatched_files: true + generate_release_notes: true + + promote-homebrew: + name: Promote Homebrew tap metadata + needs: publish + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + repository: ivankuznetsov/homebrew-hive + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + - uses: actions/download-artifact@v4 + with: + name: downstream-metadata + path: package-metadata + - run: | + cp package-metadata/homebrew-tap/Formula/hive.rb tap/Formula/hive.rb + cd tap + git config user.name hive-release-bot + git config user.email hive-release-bot@users.noreply.github.com + git add Formula/hive.rb + git commit -m "hive ${{ github.ref_name }}" + git push + + promote-aur: + name: Promote AUR hive-bin metadata + needs: publish + runs-on: ubuntu-22.04 + steps: + - uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + - uses: actions/download-artifact@v4 + with: + name: downstream-metadata + path: package-metadata + - run: | + git clone ssh://aur@aur.archlinux.org/hive-bin.git aur + cp package-metadata/aur/hive-bin/PKGBUILD aur/PKGBUILD + cp package-metadata/aur/hive-bin/.SRCINFO aur/.SRCINFO + cd aur + git config user.name hive-release-bot + git config user.email hive-release-bot@users.noreply.github.com + git add PKGBUILD .SRCINFO + git commit -m "hive-bin ${{ github.ref_name }}" + git push diff --git a/.github/workflows/tier1-e2e.yml b/.github/workflows/tier1-e2e.yml new file mode 100644 index 00000000..5edcd61e --- /dev/null +++ b/.github/workflows/tier1-e2e.yml @@ -0,0 +1,47 @@ +name: Tier-1 install e2e + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + ubuntu-2204: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: bundle exec rake test + - run: bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }' + - run: bash scripts/test-installer-fixtures + + macos-14-arm64: + runs-on: macos-14 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + bundler-cache: true + - run: bundle exec rake test + - run: bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }' + - run: bash scripts/test-installer-fixtures + + arch-x86_64: + runs-on: ubuntu-22.04 + container: archlinux:base + steps: + - name: Prepare clean Arch host + run: pacman -Syu --noconfirm git ruby base-devel curl + - uses: actions/checkout@v6 + - name: Run acceptance and installer contracts + run: | + bundle install + bundle exec rake test + bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }' + bash scripts/test-installer-fixtures diff --git a/.tebako.yml b/.tebako.yml new file mode 100644 index 00000000..d6bd1866 --- /dev/null +++ b/.tebako.yml @@ -0,0 +1,18 @@ +# Tebako bundle configuration for runtime-free Hive releases (KTD1). +# Build with the release scripts under scripts/; pin the exact Ruby patch +# that matches .ruby-version. +--- +entry_point: bin/hive +tebafile: hive +ruby_version: "3.4.7" +package_mode: bundle +# Portable glibc Linux builds target the oldest supported baseline so the +# artifact runs on Ubuntu 22.04+ and current Arch (R4, R19). +forward_portability: true +include: + - lib/ + - bin/hive + - templates/ + - schemas/ + - LICENSE + - config.example.yml diff --git a/INSTALL_PROMPT.md b/INSTALL_PROMPT.md new file mode 100644 index 00000000..6cb77007 --- /dev/null +++ b/INSTALL_PROMPT.md @@ -0,0 +1,83 @@ +# Install Hive (agent prompt) + +Paste this into Claude Code, Codex, or Pi. The agent must stop at every hard boundary below and must not invent credentials or edit unrelated files. + +## Goal + +Install the self-contained Hive CLI on this host, verify it, optionally run `hive init` in the current project, and optionally install the separate `hive-skills` package through the host agent's native mechanism. + +## Hard stop conditions + +- If the OS/arch is not tier-1 (`macOS 14+ arm64`, `Ubuntu 22.04+ x86_64`, or current Arch x86_64), explain the support boundary from `docs/install.md` and stop. +- If an existing `hive` on `PATH` is **not** this product (e.g. Apache Hive), do **not** overwrite it. Install with the Bash channel and `--command-name hv`, then use `hv` for every later step. +- Never install package managers, agent CLIs, or provider credentials without explicit user consent. +- Never write Claude/Codex/Pi skills from `hive init` or the core installer. +- Never enable or start the daemon without asking (interactive) or an explicit flag. + +## Channel selection + +1. Detect OS and architecture (`uname -s`, `uname -m`). +2. Detect an unrelated `hive` (`command -v hive` + version/help probe). +3. Choose exactly one channel: + - **Unrelated `hive` present** → Bash installer with `--command-name hv`. + - **macOS arm64 + Homebrew available** → `brew install ivankuznetsov/hive/hive` (fully qualified tap). + - **Arch + AUR helper (`yay`/`paru`)** → `yay -S hive-bin` (or paru). + - **Otherwise (including Ubuntu)** → verified Bash installer. +4. After install, run `hive --version` or `hv --version` and refuse to continue if verification fails. + +## Bash installer (fallback / collision) + +Safer two-step flow (preferred over piping curl to bash when the user can confirm): + +```bash +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh -o /tmp/hive-install.sh +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/SHA256SUMS -o /tmp/hive-SHA256SUMS +expected=$(awk '$2 == "install.sh" { print $1 }' /tmp/hive-SHA256SUMS) +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum /tmp/hive-install.sh | awk '{ print $1 }') +else + actual=$(shasum -a 256 /tmp/hive-install.sh | awk '{ print $1 }') +fi +test -n "$expected" && test "$actual" = "$expected" +bash /tmp/hive-install.sh --version 0.1.0 --command-name hv # or hive +``` + +One-liner (checksum still verifies the archive payload): + +```bash +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash -s -- --version 0.1.0 +``` + +## After core install + +1. Ask before initialization, then collect provider, model, and the name of an + environment variable containing the credential. Run, for example, + `hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY`. + Never request or store the credential value. +2. `init` registers a user service but must not autostart non-interactively. +3. Offer skills install only through a native mechanism: + +### Claude Code + +```bash +claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +claude plugin install hive@hive-skills +``` + +### Codex + +```bash +codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +codex plugin add hive@hive-skills +``` + +### Pi + +```bash +pi install npm:@ivankuznetsov/hive-skills@0.1.0 +``` + +If marketplace/package policy blocks the install, print the manual command and stop without copying files into dot-directories. + +4. Recommend a fresh agent session after skills install. +5. Report the final command name (`hive` or `hv`), version, channel used, and next steps. diff --git a/README.md b/README.md index 1fd7a0ff..c08e49c9 100644 --- a/README.md +++ b/README.md @@ -51,26 +51,14 @@ Hive's other primary surface is a coding agent — Claude Code, Codex, Gemini, P 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`. +Follow INSTALL_PROMPT.md from this repository exactly. Install the self-contained +v0.1.0 release through the supported Homebrew, AUR, or verified Bash channel; +do not clone the source tree or require a system Ruby. Preserve an unrelated +`hive` command by selecting the collision-safe `hv` Bash install. Verify the +installed command's exact version before continuing. If I approve `init`, ask +for the provider, model, and credential environment-variable name, and never +ask for or persist the credential value. Report the chosen channel, command +path, and verified version. ``` ### Operate Hive day-to-day via an agent @@ -86,7 +74,24 @@ 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. +Hive is distributed as a **runtime-free** CLI from immutable GitHub Releases — no system Ruby is required for normal use. See [docs/install.md](docs/install.md) for the full support matrix, collision-safe `hv` install, and agent prompt. + +```bash +# macOS arm64 (Homebrew tap) +brew install ivankuznetsov/hive/hive + +# Arch x86_64 +yay -S hive-bin + +# Ubuntu / fallback (verified Bash installer) +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash +``` + +If an unrelated `hive` (e.g. Apache Hive) is already on `PATH`, install with `--command-name hv` and use `hv` thereafter. Agent-driven install: paste [INSTALL_PROMPT.md](INSTALL_PROMPT.md). Skills are a **separate** package — see [packaging/hive-skills](packaging/hive-skills/README.md). + +### Development install (contributors) + +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). ```bash git clone https://github.com/ivankuznetsov/hive ~/Dev/hive @@ -96,8 +101,6 @@ mkdir -p ~/.local/bin ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive ``` -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`. - ## Power-User / Scripting CLI The TUI is the recommended human interface and an agent-driven CLI is the recommended automation surface, but every workflow verb is also available directly on `bin/hive` for scripting, debugging, and recovery. Each verb supports `--json` and returns a typed envelope, so wrapper scripts get structured output. @@ -121,4 +124,7 @@ Full per-command reference, every flag, every envelope field, and every exit cod - **[docs/cli.md](docs/cli.md)** — The full command surface exposed by `bin/hive`: every verb, every flag, every `--json` envelope contract, and every exit code. Read this when you're scripting Hive or wiring it into an agent that needs the full CLI map. - **[docs/recipes.md](docs/recipes.md)** — Concrete end-to-end workflows, including the xbookmark dogfood replay (linked to the real PR and a committed transcript of the run). Read this when you want to see what a complete idea-to-PR run looks like before trying it yourself. - **[docs/faq.md](docs/faq.md)** — Troubleshooting and design-rationale answers: why folders instead of a database, why per-stage subprocesses instead of a long-running orchestrator, why commit `.hive-state/` to an orphan branch, why opt-in daemon, why no built-in web UI. Read this when you hit a surprise or want to know "why is it like this?". +- **[docs/install.md](docs/install.md)** — Distribution channels (Homebrew, AUR, Bash), XDG layout, update/uninstall, and skills package. +- **[docs/daemon.md](docs/daemon.md)** — Per-user LaunchAgent / systemd registration and lifecycle. +- **[docs/update-uninstall.md](docs/update-uninstall.md)** — Channel-aware update and non-destructive uninstall. - **[wiki/index.md](wiki/index.md)** — The catalog of the LLM-maintained engineering wiki under `wiki/`, which is the deepest source of reference material for every command, module, and stage. Read this when the user-facing docs above don't have the depth you need. diff --git a/Rakefile b/Rakefile index 9f5959cd..6e41cec7 100644 --- a/Rakefile +++ b/Rakefile @@ -5,7 +5,7 @@ require "rake/testtask" Rake::TestTask.new do |t| t.libs << "test" t.libs << "lib" - t.test_files = FileList["test/{unit,integration}/**/*_test.rb"] + t.test_files = FileList["test/{unit,integration,acceptance}/**/*_test.rb"] t.warning = false end diff --git a/bin/hive b/bin/hive index bfd53884..53dd2d8c 100755 --- a/bin/hive +++ b/bin/hive @@ -1,12 +1,34 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + +# Invocation-name-neutral entry point: works as `hive` or `hv` (R6, R20). +# Packaged builds (Tebako) may replace this with a self-contained binary that +# still dispatches through Hive::CLI. $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "hive" require "hive/cli" +require "hive/paths" +require "hive/assets" +require "hive/dependency_check" + +# Materialize versioned assets on first run so packaged installs share one +# XDG data lookup path regardless of install channel. +begin + Hive::Assets.materialize! +rescue StandardError + # Non-fatal: development checkouts already resolve templates from source. + nil +end -if ARGV == [ "--version" ] || ARGV == [ "-v" ] - puts Hive::VERSION +if ARGV == ["--version"] || ARGV == ["-v"] + build = ENV["HIVE_BUILD_ID"] + if build && !build.empty? + puts "#{Hive::VERSION}+#{build}" + else + puts Hive::VERSION + end exit 0 end diff --git a/bin/hv b/bin/hv new file mode 120000 index 00000000..0994cb5f --- /dev/null +++ b/bin/hv @@ -0,0 +1 @@ +hive \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index b7de5922..f69ad325 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,10 +16,8 @@ Hive is a Ruby CLI around filesystem state, agent subprocesses, and git worktree `-- / # feature worktree created by 4-execute `-- app files... -~/Dev/hive/ -|-- bin/hive -|-- lib/hive/ -`-- config.yml # global registry +$XDG_CONFIG_HOME/hive/ +`-- config.yml # global registry + provider references ``` The project checkout holds code. `.hive-state/` holds durable Hive state on the separate `hive/state` branch. The feature worktree holds code changes for one task branch. @@ -76,7 +74,9 @@ hive doctor --json ## Config Schema -Global registry lives at `~/Dev/hive/config.yml`: +Global registry and credential references live at +`$XDG_CONFIG_HOME/hive/config.yml` (or `HIVE_HOME/config.yml` when explicitly +overridden): ```yaml registered_projects: diff --git a/docs/cli.md b/docs/cli.md index 082ad50f..777b2214 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -54,7 +54,7 @@ The daemon is optional and per-project. It polls `hive status --json`, dispatche ```bash hive daemon enable hive daemon enable --all -hive daemon start --dry-run --detach +hive daemon start hive daemon status hive daemon tail hive daemon stop @@ -88,7 +88,7 @@ Workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `finalize` |---:|---| | 0 | Success. | | 1 | Generic failure. | -| 2 | Already initialized. | +| 2 | Reserved compatibility code; rerunning `hive init` repairs existing state. | | 3 | Task is in an error marker state. | | 4 | Wrong stage. | | 64 | Usage error. | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..5b9e5339 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,48 @@ +# Configuration + +## XDG layout + +Hive honors the XDG Base Directory Specification: + +| Variable | Default | Contents | +|----------|---------|----------| +| `XDG_CONFIG_HOME` | `~/.config` | `hive/config.yml` user provider/model config | +| `XDG_DATA_HOME` | `~/.local/share` | versioned assets, install receipt | +| `XDG_STATE_HOME` | `~/.local/state` | durable state, daemon logs | +| `XDG_CACHE_HOME` | `~/.cache` | caches | +| `XDG_RUNTIME_DIR` | platform temp private dir | sockets/locks | + +Override the combined registry/daemon home with `HIVE_HOME` (tests and advanced operators). + +## User config + +`$XDG_CONFIG_HOME/hive/config.yml` (mode `0600`) stores **references**, never plaintext secrets: + +```yaml +provider: openai +model: gpt-5 +api_key_env: OPENAI_API_KEY +``` + +`hive init` collects these values on its first interactive run. For automation, +provide all three explicitly: + +```bash +hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY +``` + +The named environment variable must contain the credential before Hive invokes +that provider. Hive stores the variable name, never its value. A non-interactive +first run without complete choices fails with the exact flags to supply. + +## Project config + +Per-project pipeline settings live in `/.hive-state/config.yml` (created by `hive init`). Ownership metadata for generated scaffold files lives in `/.hive/manifest.yml`. + +## Global registry + +Registered projects are listed in the global config (`HIVE_HOME/config.yml` or `$XDG_CONFIG_HOME/hive/config.yml`). + +On first use, a legacy `~/Dev/hive/config.yml` is merged non-destructively into +the active XDG config: existing XDG values win and previously registered project +rows are retained. The legacy file remains in place. diff --git a/docs/daemon.md b/docs/daemon.md new file mode 100644 index 00000000..7c0561c2 --- /dev/null +++ b/docs/daemon.md @@ -0,0 +1,41 @@ +# Daemon and user services + +## Commands + +```bash +hive daemon start # start the registered user service +hive daemon run # internal service-manager entry (foreground) +hive daemon stop +hive daemon status [--json] +hive daemon reload +hive daemon tail +hive daemon enable PROJECT|--all +hive daemon disable PROJECT|--all +``` + +## Per-user service registration + +`hive init` registers a user service definition but **does not** enable/start it non-interactively: + +- **macOS:** `~/Library/LaunchAgents/dev.hive.daemon.plist` +- **Linux:** `$XDG_CONFIG_HOME/systemd/user/hive.service` + +On a TTY, init asks once before starting. On non-TTY, it prints: + +```text +hive: service registered (not started; non-interactive). Start later with: hive daemon start +``` + +Service definitions use the **stable** command path (`hive` / `hv` symlink), not a versioned Cellar realpath, so upgrades do not break the unit. + +Public `start`, `stop`, and `status` commands control the registered launchd or +`systemd --user` service. If no service is registered, they fall back to direct +process control for compatibility. Service-manager availability and lifecycle +failures are reported as failures; Hive does not claim that a daemon started +when the user bus or launch service rejected it. + +## Enablement vs running + +- **Enabled** is service-manager state (systemd enable / LaunchAgent loaded). +- **Running** is whether the process is up. +- `hive update` refreshes the definition and preserves both. diff --git a/docs/faq.md b/docs/faq.md index be4353a4..73d18f56 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -20,7 +20,9 @@ Hive state changes often and should not pollute the project's code history or tr ### Why opt-in daemon, not always-on? -The daemon can spend real agent time and move many tasks. Per-project enrollment is the durable consent signal, and `--dry-run` lets you inspect dispatches before live mode. +The daemon can spend real agent time and move many tasks. Per-project enrollment +is the durable consent signal, and the registered user service does not start +non-interactively without explicit operator action. ### Why no built-in web UI? @@ -32,9 +34,10 @@ Hive treats agent CLIs as profiles. Planning, implementation, review, and browse ## Troubleshooting -### `already initialized` +### A previous `hive init` stopped partway through -Cause: the project already has a `hive/state` branch. Fix: skip `hive init` or use the existing `.hive-state/`. Exit code: 2. +Rerun the same `hive init` command. Existing branch/worktree state is reused and +missing config, scaffold, registry, migration, or service steps are repaired. ### `not a git repository` diff --git a/docs/getting-started.md b/docs/getting-started.md index 162eb713..8a3c8b59 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,15 +6,19 @@ The example is xbookmark, a real Hive dogfood task that finished as [xbookmark P ## Prerequisites -You need Ruby 3.4, git >= 2.40, `claude` authenticated, `codex` installed for the default execute agent, `gh` authenticated, and a git checkout you can modify. The commands below use `~/Dev/xbookmark`; substitute your own project path and project name when running against another repo. +You need git >= 2.40, `claude` authenticated, `codex` installed for the default execute agent, `gh` authenticated, and a git checkout you can modify. Normal installs do not require Ruby. The commands below use `~/Dev/xbookmark`; substitute your own project path and project name when running against another repo. ## Step 1 - Install ```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 +brew install ivankuznetsov/hive/hive # macOS 14+ arm64 +# yay -S hive-bin # Arch x86_64 +# curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash # Ubuntu ``` -If `~/.local/bin` is not on your `PATH`, put the symlink in a directory that is before running the next step. The `-sf` form will overwrite an existing `hive` at the target path, so check `command -v hive` first if you already have one installed. +Use the channel for your platform from [docs/install.md](install.md). If an +unrelated `hive` already exists, use the verified Bash installer with +`--command-name hv` and substitute `hv` below. ```bash hive --version @@ -24,10 +28,14 @@ hive --version ```bash cd ~/Dev/xbookmark -hive init . +hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY ``` -`hive init` creates `.hive-state/` as a worktree of the orphan `hive/state` branch, registers the project in `~/Dev/hive/config.yml`, and scaffolds the stage folders. Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout). +`hive init` creates `.hive-state/` as a worktree of the orphan `hive/state` +branch, registers the project in `$XDG_CONFIG_HOME/hive/config.yml`, stores only +the credential environment-variable reference, and scaffolds the stage folders. +It is safe to rerun after a partial initialization; missing pieces are repaired. +Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout). ## Step 3 - Capture The Idea diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..591f1565 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,100 @@ +# Installing Hive + +Hive ships as a runtime-free CLI (Tebako-packaged Ruby) from immutable GitHub Releases. You do **not** need a system Ruby. + +## Support matrix + +| Tier | Host | Channel | +|------|------|---------| +| 1 | macOS 14+ arm64 | Homebrew tap | +| 1 | Ubuntu 22.04+ x86_64 | Bash installer | +| 1 | Arch Linux x86_64 | AUR `hive-bin` | +| 2 | Other macOS/Linux | Best-effort Bash | +| 3 | Windows (native), others | Unsupported — use WSL2 tier-1 Linux | + +## Homebrew (macOS arm64) + +```bash +brew install ivankuznetsov/hive/hive +hive --version +``` + +Exposes both `hive` and `hv`. If Apache Hive already owns `hive` on your PATH, use the Bash installer with `--command-name hv` instead of overwriting it. + +## AUR (Arch x86_64) + +```bash +yay -S hive-bin +# or: paru -S hive-bin +hive --version +``` + +## Bash installer (Ubuntu and fallback) + +Safer two-step verification: + +```bash +TAG=v0.1.0 +BASE=https://github.com/ivankuznetsov/hive/releases/download/$TAG +curl -fsSL "$BASE/install.sh" -o /tmp/hive-install.sh +curl -fsSL "$BASE/SHA256SUMS" -o /tmp/hive-SHA256SUMS +expected=$(awk '$2 == "install.sh" { print $1 }' /tmp/hive-SHA256SUMS) +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum /tmp/hive-install.sh | awk '{ print $1 }') +else + actual=$(shasum -a 256 /tmp/hive-install.sh | awk '{ print $1 }') +fi +test -n "$expected" && test "$actual" = "$expected" +bash /tmp/hive-install.sh --version 0.1.0 +``` + +Collision-safe alias: + +```bash +bash /tmp/hive-install.sh --version 0.1.0 --command-name hv +hv --version +``` + +One-liner (archive still SHA-256 verified): + +```bash +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash +``` + +Installs under `$XDG_DATA_HOME/hive/versions/` and links both `hive` +and `hv` into `~/.local/bin`. A collision-safe `--command-name hv` install +creates only `hv`. Custom-prefix installs still write their ownership receipt to +`$XDG_DATA_HOME/hive/install-receipt.yml`, where update and uninstall can find +it. The installer never edits your shell profile; it prints a PATH hint when +needed. + +## Agent-driven install + +Paste [INSTALL_PROMPT.md](../INSTALL_PROMPT.md) into Claude Code, Codex, or Pi. The prompt selects the safest channel, verifies `--version`, offers `init`, and optionally installs `hive-skills` through native marketplace/package commands. + +## Skills (separate package) + +Core Hive never installs agent skills. Use the [hive-skills](../packaging/hive-skills/README.md) package: + +```bash +# Claude +claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +claude plugin install hive@hive-skills + +# Codex +codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +codex plugin add hive@hive-skills + +# Pi +pi install npm:@ivankuznetsov/hive-skills@0.1.0 +``` + +## Next steps + +```bash +cd your-project +hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY +hive tui +``` + +See also: [configuration](configuration.md), [daemon](daemon.md), [update & uninstall](update-uninstall.md), [migration](migration.md), [troubleshooting](troubleshooting.md). diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..6a639dc4 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,21 @@ +# Migration from legacy layouts + +## Legacy locations + +| Legacy | Role | +|--------|------| +| `~/Dev/hive` | Previous global home (registry, daemon pid/logs) | +| `~/.hive-state` | Possible legacy global state | + +## What Hive does + +On `hive init`, if legacy trees exist, Hive **copies and verifies** them into +`$XDG_STATE_HOME/hive/legacy-import/` and writes `.migration.yml`. It also +merges the legacy registry and daemon consent into +`$XDG_CONFIG_HOME/hive/config.yml`: current XDG values win, while project rows +missing from the active registry are adopted. The legacy directories are +**retained** until you remove them yourself. + +## Project state + +Per-project `.hive-state/` git worktrees are unchanged by the XDG migration. The new `.hive/` directory only tracks install/uninstall ownership for generated scaffold files. diff --git a/docs/recipes.md b/docs/recipes.md index 0d3ee3f3..993de189 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -49,19 +49,18 @@ hive status hive status --json ``` -For automation, enroll projects and do a dry run before live dispatch: +For automation, enroll projects and inspect their status before live dispatch: ```bash hive daemon enable --all -hive daemon start --dry-run --detach -hive daemon tail +hive daemon status ``` -When the dry-run dispatches look right: +When enrollment looks right, start the registered user service: ```bash -hive daemon stop -hive daemon start --detach +hive daemon start +hive daemon tail ``` ## Recover From REVIEW_STALE diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..2bddd3a2 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,39 @@ +# Troubleshooting installs + +## `hive` is Apache Hive / wrong binary + +```bash +command -v hive +hive --version +``` + +If it is not this product, install with: + +```bash +bash install.sh --command-name hv +hv --version +``` + +## `~/.local/bin` not on PATH + +The Bash installer prints the exact export line. It never edits your profile. + +## Daemon won't register on Linux + +Headless sessions without a user systemd bus cannot register or start the user +service. Hive reports that failure. Establish a user session with a working +`systemctl --user` bus, then rerun `hive init` to repair registration before +running `hive daemon start`. + +## Update says ownership unknown + +Reinstall through the channel you prefer (brew / AUR / install.sh) so provenance is unambiguous. Bash installs write `$XDG_DATA_HOME/hive/install-receipt.yml`. + +## Checksum / attestation failures + +- SHA-256 is mandatory; a mismatch aborts before extraction. +- `gh attestation verify` is optional; when `gh` is missing you get a warning after a successful checksum. + +## Unsupported platform + +Tier-3 hosts receive a clear message and no partial install. Use a tier-1 environment or WSL2 Ubuntu 22.04+. diff --git a/docs/update-uninstall.md b/docs/update-uninstall.md new file mode 100644 index 00000000..e06cefe2 --- /dev/null +++ b/docs/update-uninstall.md @@ -0,0 +1,42 @@ +# Update and uninstall + +## Update + +```bash +hive update +# or +hv update +``` + +Hive detects ownership and delegates: + +| Channel | Command | +|---------|---------| +| Homebrew | `brew upgrade ivankuznetsov/hive/hive` | +| AUR | `yay -Syu hive-bin` (or paru / pacman) | +| Bash | download and verify a version-pinned `install.sh`, then run it | + +Ambiguous or unknown ownership stops with recovery instructions — Hive never guesses or in-place swaps package-manager files. + +## Uninstall + +```bash +hive uninstall # preview + confirm +hive uninstall --yes # non-interactive +hive uninstall --yes --clean-project +``` + +What is removed: + +- Per-user service registration +- Binary via the owning channel (`brew uninstall ivankuznetsov/hive/hive`, AUR `-R`, or verified Bash symlinks + receipt) + +What is **preserved by default**: + +- `$XDG_CONFIG_HOME/hive` +- `$XDG_STATE_HOME/hive` (and legacy `~/Dev/hive` / `~/.hive-state` if present) +- Completed pipeline outputs +- Modified project files +- Agent-installed skills + +`--clean-project` only deletes **unmodified** files listed in the current project's `.hive/manifest.yml` (hash-verified). Modified or unknown files are always kept. diff --git a/hive.gemspec b/hive.gemspec new file mode 100644 index 00000000..9294c7a2 --- /dev/null +++ b/hive.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "lib/hive" + +Gem::Specification.new do |spec| + spec.name = "hive" + spec.version = Hive::VERSION + spec.authors = ["Ivan Kuznetsov"] + spec.email = [] + + spec.summary = "Multi-agent folder-as-state software pipeline" + spec.description = "Hive turns a rough software idea into a merge-ready pull request " \ + "through a multi-agent pipeline with a TUI and daemon." + spec.homepage = "https://github.com/ivankuznetsov/hive" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.4.0" + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + f.start_with?("test/", "docs/plans/", "docs/brainstorms/", "vendor/", ".git/") + end + end + spec.bindir = "bin" + spec.executables = %w[hive] + spec.require_paths = ["lib"] + + spec.add_dependency "thor", "~> 1.3" + spec.add_dependency "telegram-bot-ruby", "~> 2.7" + spec.add_dependency "bubbletea", "= 0.1.4" + spec.add_dependency "lipgloss", "~> 0.2.2" + + spec.metadata["source_code_uri"] = "https://github.com/ivankuznetsov/hive" + spec.metadata["changelog_uri"] = "https://github.com/ivankuznetsov/hive/blob/main/CHANGELOG.md" +end diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..2a7d2ee8 --- /dev/null +++ b/install.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# Hive verified Bash installer (R5-R7, U7). +# Downloads a pinned GitHub Release archive, verifies SHA-256, installs +# under $XDG_DATA_HOME/hive/versions/, and links into ~/.local/bin. +# +# Usage: +# curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash +# bash install.sh --version 0.1.0 --command-name hv +set -euo pipefail + +HIVE_VERSION="${HIVE_VERSION:-0.1.0}" +COMMAND_NAME="hive" +REPO="${HIVE_REPO:-ivankuznetsov/hive}" +BASE_URL="${HIVE_RELEASE_BASE:-https://github.com/${REPO}/releases/download/v${HIVE_VERSION}}" +PREFIX="" +DRY_RUN=0 +SKIP_PROVENANCE=0 + +usage() { + cat <&2; usage; exit 64 ;; + esac +done + +if [[ ! "$HIVE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: --version must be MAJOR.MINOR.PATCH, got: ${HIVE_VERSION}" >&2 + exit 64 +fi + +if [[ "$COMMAND_NAME" != "hive" && "$COMMAND_NAME" != "hv" ]]; then + echo "error: --command-name must be hive or hv" >&2 + exit 64 +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: missing required tool '$1' ($2)" >&2 + exit 1 + fi +} + +need curl "install curl to download release assets" +need tar "install tar to extract archives" +if command -v sha256sum >/dev/null 2>&1; then + sha256_file() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; } +else + echo "error: need sha256sum or shasum for integrity verification" >&2 + exit 1 +fi + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$os" in + darwin) platform=macos ;; + linux) platform=linux ;; + *) echo "error: unsupported OS '$os' (tier-3). See docs/install.md" >&2; exit 1 ;; +esac +case "$arch" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=x86_64 ;; + *) echo "error: unsupported architecture '$arch'" >&2; exit 1 ;; +esac + +if [[ "$platform" == "macos" && "$arch" != "arm64" ]]; then + echo "warn: macOS $arch is tier-2 best-effort" >&2 +elif [[ "$platform" == "macos" ]]; then + macos_major=$(sw_vers -productVersion 2>/dev/null | cut -d. -f1 || true) + if [[ ! "$macos_major" =~ ^[0-9]+$ ]] || (( macos_major < 14 )); then + echo "warn: macOS ${macos_major:-unknown} arm64 is tier-2 best-effort; tier-1 starts at macOS 14" >&2 + else + echo "hive install: host classification macOS ${macos_major} arm64 (tier-1)" + fi +fi +if [[ "$platform" == "linux" && "$arch" != "x86_64" ]]; then + echo "warn: Linux $arch is tier-2 best-effort" >&2 +elif [[ "$platform" == "linux" ]]; then + distro=unknown + distro_version=unknown + if [[ -r /etc/os-release ]]; then + distro=$(awk -F= '$1=="ID" {gsub(/["\047]/, "", $2); print $2}' /etc/os-release) + distro_version=$(awk -F= '$1=="VERSION_ID" {gsub(/["\047]/, "", $2); print $2}' /etc/os-release) + fi + ubuntu_tier1=0 + if [[ "$distro" == "ubuntu" ]] && awk -v actual="$distro_version" 'BEGIN { split(actual,a,"."); exit !((a[1]+0)>22 || ((a[1]+0)==22 && (a[2]+0)>=4)) }'; then + ubuntu_tier1=1 + fi + if [[ "$distro" == "arch" || "$ubuntu_tier1" -eq 1 ]]; then + echo "hive install: host classification Linux ${distro} ${distro_version} x86_64 (tier-1)" + else + echo "warn: Linux ${distro} ${distro_version} x86_64 is tier-2 best-effort; tier-1 is Ubuntu 22.04+ or current Arch" >&2 + fi +fi + +XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +XDG_BIN_HOME="${XDG_BIN_HOME:-$HOME/.local/bin}" +if [[ -n "$PREFIX" ]]; then + if [[ "$PREFIX" != /* ]]; then + echo "error: --prefix must be an absolute path" >&2 + exit 64 + fi + DATA_ROOT="$PREFIX" +else + DATA_ROOT="${XDG_DATA_HOME}/hive" +fi +for path_value in "$XDG_DATA_HOME" "$XDG_BIN_HOME" "$DATA_ROOT"; do + if [[ "$path_value" != /* || "$path_value" == *$'\n'* || "$path_value" == *'"'* || "$path_value" == *'\'* ]]; then + echo "error: install paths must be absolute and cannot contain quotes, backslashes, or newlines: ${path_value}" >&2 + exit 64 + fi +done +VERSION_DIR="${DATA_ROOT}/versions/${HIVE_VERSION}" +ARCHIVE="hive-${platform}-${arch}.tar.gz" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +echo "hive install: version=${HIVE_VERSION} platform=${platform}-${arch} command=${COMMAND_NAME}" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "dry-run: would fetch ${BASE_URL}/${ARCHIVE}" + echo "dry-run: would install into ${VERSION_DIR}" + echo "dry-run: would link ${XDG_BIN_HOME}/${COMMAND_NAME}" + [[ "$COMMAND_NAME" == "hive" ]] && echo "dry-run: would link ${XDG_BIN_HOME}/hv" + exit 0 +fi + +# Refuse to overwrite any unrelated command. A prior Bash-channel symlink +# into this install's versions root is the only command we replace. +preflight_names=("$COMMAND_NAME") +[[ "$COMMAND_NAME" == "hive" ]] && preflight_names=("hive" "hv") +for name in "${preflight_names[@]}"; do + if [[ -e "${XDG_BIN_HOME}/${name}" || -L "${XDG_BIN_HOME}/${name}" ]]; then + existing="${XDG_BIN_HOME}/${name}" + else + existing=$(command -v "$name" 2>/dev/null || true) + fi + if [[ -n "$existing" ]]; then + target=$(readlink "$existing" 2>/dev/null || true) + if [[ ! -L "$existing" || ( "$target" != "${DATA_ROOT}/versions/"* && "$target" != "${XDG_DATA_HOME}/hive/versions/"* ) ]]; then + echo "error: '$existing' already exists and is not owned by this Bash install." >&2 + if [[ "$name" == "hive" ]]; then + echo " Re-run with --command-name hv to preserve it and install the collision-safe alias." >&2 + fi + exit 1 + fi + fi +done + +cd "$TMP" +echo "hive install: downloading manifest + archive" +curl -fsSL "${BASE_URL}/manifest.json" -o manifest.json +curl -fsSL "${BASE_URL}/SHA256SUMS" -o SHA256SUMS +curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${ARCHIVE}" + +# Verify digest before extraction. +expected=$(awk -v f="$ARCHIVE" '$2==f {print $1}' SHA256SUMS) +if [[ -z "$expected" ]]; then + # Try basenames with */ + expected=$(awk -v f="$ARCHIVE" '$2 ~ f"$" {print $1}' SHA256SUMS) +fi +actual=$(sha256_file "$ARCHIVE") +if [[ -z "$expected" || "$expected" != "$actual" ]]; then + echo "error: SHA-256 mismatch for ${ARCHIVE}" >&2 + echo " expected: ${expected:-}" >&2 + echo " actual: ${actual}" >&2 + exit 1 +fi + +# Cross-check manifest entry when ruby is available (checksum already verified). +if command -v ruby >/dev/null 2>&1; then + ARCHIVE="$ARCHIVE" ACTUAL="$actual" ruby -e ' + require "json" + archive = ENV.fetch("ARCHIVE") + actual = ENV.fetch("ACTUAL") + m = JSON.parse(File.read("manifest.json")) + a = m.fetch("assets").find { |x| x["filename"] == archive } + abort "archive not listed in manifest" unless a + abort "manifest sha256 mismatch" unless a["sha256"] == actual + abort "refusing mutable latest URL" if a["url"].to_s.include?("/latest/") + puts "manifest ok" + ' +fi + +if [[ "$SKIP_PROVENANCE" -eq 0 ]] && command -v gh >/dev/null 2>&1; then + if ! gh attestation verify "${ARCHIVE}" --repo "$REPO" 2>/dev/null; then + echo "warn: gh attestation verify failed or unavailable; checksum verification passed" >&2 + fi +elif [[ "$SKIP_PROVENANCE" -eq 0 ]]; then + echo "warn: gh not installed; skipped provenance attestation (checksum verified)" >&2 +fi + +# Validate every member before extraction. Release archives contain only +# regular files/directories beneath hive/; links and special files are not +# needed and are rejected rather than trying to normalize attacker targets. +mkdir -p extract +tar -tzf "${ARCHIVE}" >members.txt +while IFS= read -r member; do + case "$member" in + hive|hive/|hive/*) ;; + *) echo "error: unsafe archive member outside hive/: ${member}" >&2; exit 1 ;; + esac + if [[ "$member" == /* || "/$member/" == *"/../"* || "/$member/" == *"/./"* ]]; then + echo "error: unsafe archive member path: ${member}" >&2 + exit 1 + fi +done &2 + exit 1 +fi +tar -xzf "${ARCHIVE}" -C extract +if find extract -type l | grep -q .; then + echo "error: archive extraction produced an unexpected symlink" >&2 + exit 1 +fi + +# Stage and validate the complete payload before touching a previous install. +stage="${VERSION_DIR}.staging.$$" +rm -rf "$stage" +mkdir -p "$stage" +cp -a extract/hive/. "$stage/" 2>/dev/null || cp -a extract/. "$stage/" +staged_bin="${stage}/hive" +if [[ ! -x "$staged_bin" ]]; then + staged_bin="${stage}/bin/hive" +fi +if [[ ! -x "$staged_bin" ]]; then + echo "error: packaged binary not found in archive" >&2 + exit 1 +fi +staged_version=$("$staged_bin" --version) +if [[ "$staged_version" != "$HIVE_VERSION" ]]; then + echo "error: packaged binary version ${staged_version:-} does not match requested ${HIVE_VERSION}" >&2 + exit 1 +fi + +backup="${VERSION_DIR}.rollback.$$" +had_previous=0 +rm -rf "$backup" +mkdir -p "${XDG_BIN_HOME}" +if [[ -e "$VERSION_DIR" ]]; then + mv "$VERSION_DIR" "$backup" + had_previous=1 +fi +rollback_install() { + rm -rf "$VERSION_DIR" + if [[ "$had_previous" -eq 1 && -e "$backup" ]]; then + mv "$backup" "$VERSION_DIR" + else + # Remove only links this invocation was authorized to create. A + # collision-safe hv install must never remove an unrelated `hive`. + for rollback_name in "${preflight_names[@]}"; do + rm -f "${XDG_BIN_HOME}/${rollback_name}" + done + fi +} +if ! mv "$stage" "$VERSION_DIR"; then + rollback_install + exit 1 +fi + +bin_src="${VERSION_DIR}/hive" +[[ -x "$bin_src" ]] || bin_src="${VERSION_DIR}/bin/hive" +command_names=("$COMMAND_NAME") +if [[ "$COMMAND_NAME" == "hive" ]]; then + command_names=("hive" "hv") +fi +for name in "${command_names[@]}"; do + if ! ln -sfn "$bin_src" "${XDG_BIN_HOME}/${name}"; then + rollback_install + exit 1 + fi +done + +for name in "${command_names[@]}"; do + if ! verified_version=$("${XDG_BIN_HOME}/${name}" --version) || [[ "$verified_version" != "$HIVE_VERSION" ]]; then + echo "error: post-install verification failed for ${XDG_BIN_HOME}/${name}" >&2 + rollback_install + exit 1 + fi +done +rm -rf "$backup" + +# The receipt always lives in the canonical XDG data root so lifecycle +# commands can discover custom-prefix installs. It binds every command link +# to the exact payload path and digest installed above. +RECEIPT_ROOT="${XDG_DATA_HOME}/hive" +RECEIPT_PATH="${RECEIPT_ROOT}/install-receipt.yml" +mkdir -p "$RECEIPT_ROOT" +binary_sha256=$(sha256_file "$bin_src") +cat >"$RECEIPT_PATH" </dev/null || true + +# PATH hint without editing shell profiles. +case ":$PATH:" in + *":${XDG_BIN_HOME}:"*) ;; + *) + echo "hive install: ${XDG_BIN_HOME} is not on PATH." + echo " Add this line to your shell profile:" + echo " export PATH=\"${XDG_BIN_HOME}:\$PATH\"" + ;; +esac + +export PATH="${XDG_BIN_HOME}:$PATH" +echo "hive install: verified ${COMMAND_NAME} ${HIVE_VERSION}" +[[ "$COMMAND_NAME" == "hive" ]] && echo "hive install: verified hv ${HIVE_VERSION}" +echo "hive install: complete" diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb new file mode 100644 index 00000000..c309ec53 --- /dev/null +++ b/lib/hive/assets.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "fileutils" +require "digest" +require "yaml" +require "time" +require "hive/paths" + +module Hive + # Materializes immutable package-owned assets into the versioned XDG + # data root so Homebrew, AUR, and Bash installs share one lookup path + # (KTD3). Source seeds live next to the packaged binary or in the + # development checkout under templates/, schemas/, and lib/hive/scripts/. + module Assets + SEED_GLOBS = [ + "templates/**/*", + "schemas/**/*", + "lib/hive/scripts/**/*" + ].freeze + + module_function + + def package_root + # When packaged (Tebako / installed), HIVE_PACKAGE_ROOT points at the + # immutable seed tree. In development, fall back to the repository root. + env = ENV["HIVE_PACKAGE_ROOT"] + return File.expand_path(env) if env && !env.empty? && File.directory?(env) + + File.expand_path("../..", __dir__) + end + + def active_version + Hive::VERSION + end + + def active_dir + Hive::Paths.version_dir(active_version) + end + + def materialize!(version: active_version, source_root: package_root) + dest = Hive::Paths.version_dir(version) + marker = File.join(dest, ".materialized") + return dest if File.exist?(marker) && verified?(dest) + + FileUtils.mkdir_p(dest) + assets = {} + SEED_GLOBS.each do |glob| + Dir.glob(File.join(source_root, glob), File::FNM_DOTMATCH).each do |src| + next unless File.file?(src) + + rel = src.delete_prefix(source_root.to_s).sub(%r{\A/}, "") + target = File.join(dest, rel) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.rm_f(target) + FileUtils.cp(src, target) + assets[rel] = { + "sha256" => Digest::SHA256.file(src).hexdigest, + "bytes" => File.size(src) + } + end + end + + meta = { + "version" => version, + "source_root" => source_root, + "materialized_at" => Time.now.utc.iso8601, + "assets" => assets.sort.to_h + } + Hive::Paths.write_private_file(marker, meta.to_yaml) + dest + end + + def resolve(*parts) + root = materialize! + candidate = File.join(root, *parts) + return candidate if File.exist?(candidate) + + # Development fallback: read from the package/source root directly. + File.join(package_root, *parts) + end + + def verified?(dir) + return false unless File.directory?(dir) + + marker = File.join(dir, ".materialized") + return false unless File.file?(marker) && !File.symlink?(marker) + + metadata = YAML.safe_load(File.read(marker)) || {} + assets = metadata["assets"] + return false unless assets.is_a?(Hash) && !assets.empty? + + assets.all? do |rel, expected| + next false unless expected.is_a?(Hash) + + path = File.join(dir, rel) + File.file?(path) && !File.symlink?(path) && + File.size(path) == expected["bytes"] && + Digest::SHA256.file(path).hexdigest == expected["sha256"] + end + rescue Psych::Exception, Errno::ENOENT, Errno::EACCES + false + end + end +end diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index 9bc60cb8..c09d8333 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -25,15 +25,59 @@ module Hive end map "--version" => :version - desc "init [PROJECT_PATH]", "Bootstrap .hive-state (orphan hive/state branch); TTY-prompts for agents + limits" + desc "update", "Update Hive via the owning install channel (brew / AUR / Bash)" + long_desc <<~DESC + Detects whether this `hive`/`hv` binary is owned by Homebrew, AUR + (hive-bin), or the Bash installer, then delegates to that channel. + Never swaps package-manager files in place. Refreshes the user + service definition after a successful upgrade without changing + autostart state. + + Exit codes: 0 success; 1 ownership unknown/ambiguous or updater failed. + DESC + option :dry_run, type: :boolean, default: false, desc: "print the selected updater without running it" + def update + require "hive/commands/update" + Hive::Commands::Update.new(dry_run: options[:dry_run]).call + end + + desc "uninstall", "Unregister the daemon and remove the owning binary; preserve user work" + long_desc <<~DESC + Stops and unregisters the per-user service, then delegates binary + removal to Homebrew, AUR, or the Bash installer. Config, XDG state, + completed pipeline outputs, and agent skills are preserved. + + With --clean-project, only unmodified hash-matching files from the + current project's `.hive/` manifest are offered for removal. + + Exit codes: 0 success / cancelled preview; 1 package-manager failure. + DESC + option :yes, type: :boolean, default: false, aliases: "-y", desc: "skip confirmation prompt" + option :clean_project, type: :boolean, default: false, desc: "remove unmodified manifest-owned scaffold in the current project" + option :dry_run, type: :boolean, default: false, desc: "preview only" + def uninstall + require "hive/commands/uninstall" + Hive::Commands::Uninstall.new( + yes: options[:yes], + clean_project: options[:clean_project], + dry_run: options[:dry_run] + ).call + end + + desc "init [PROJECT_PATH]", "Create or repair .hive-state and first-run user configuration" long_desc <<~DESC Initialises hive in PROJECT_PATH (defaults to the current directory): creates the orphan `hive/state` branch, attaches it as a worktree at `/.hive-state/`, scaffolds stage folders, ignores `.hive-state/` on master, and registers the project globally. - On a TTY, init asks the operator four questions before writing - anything to disk: + Existing partial initialization is repaired in place: the branch, + worktree, config, scaffold, registry, migration, and service registration + steps are all idempotent. + + On a TTY, first-time user configuration asks for provider, model, and the + name of the environment variable containing the credential. It never asks + for or writes the credential value. Project setup then asks four questions: 1. Planning agent (drives 2-brainstorm + 3-plan) — default claude 2. Development agent (drives 4-execute) — default codex @@ -44,33 +88,41 @@ module Hive OR a 1-based index. Blank input takes the default. Answer `n` at the final confirmation to abort with no disk side effects. - On non-TTY (CI, pipes, scripted callers) the prompts are skipped - and a one-line summary is emitted to stdout so the caller can see - which defaults landed: + On non-TTY (CI, pipes, scripted callers), pass the user configuration + explicitly (or through HIVE_PROVIDER, HIVE_MODEL, and HIVE_API_KEY_ENV): - hive: using defaults — planning=claude, dev=codex, reviewers=all3, limits=defaults + hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY - To set non-default values from automation, run init and then - hand-edit `.hive-state/config.yml` (see `wiki/modules/config.md` - for the schema). Piped STDIN is intentionally NOT consumed. + Missing non-interactive first-run values are a CONFIG error. Agent and + limit prompts continue to use project defaults when input is not a TTY. Exit codes: 0 — initialised successfully 1 — generic / unexpected error - 2 — already initialised (`hive/state` branch already exists) + 2 — reserved for compatibility with older releases 64 — user aborted at the confirmation prompt 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 name (or HIVE_PROVIDER)" + option :model, type: :string, desc: "first-run model name (or HIVE_MODEL)" + option :api_key_env, type: :string, + desc: "credential environment-variable name; never the credential value" 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], + api_key_env: options[:api_key_env] + ).call end desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)" long_desc <<~DESC - Drops the entry whose `name` matches NAME from ~/Dev/hive/config.yml. + Drops the entry whose `name` matches NAME from $XDG_CONFIG_HOME/hive/config.yml. The project's .hive-state directory on disk (if any) is left alone. An unknown name is a USAGE error (64), mirroring `hive metrics @@ -94,7 +146,7 @@ module Hive desc "prune", "Drop registry entries whose project path no longer exists" long_desc <<~DESC - Walks ~/Dev/hive/config.yml and removes every `registered_projects` + Walks $XDG_CONFIG_HOME/hive/config.yml and removes every `registered_projects` entry whose `path` is not a directory on disk OR whose row shape is invalid (non-Hash, missing `path`, etc. — hand-edit accidents). Useful after running `hive init` against `mktemp -d` directories @@ -481,11 +533,11 @@ module Hive desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / reload / tail / enable / disable)" long_desc <<~DESC Subcommands: - start [--detach] [--dry-run] Run the dispatcher loop. Without - --detach, runs in the foreground. - stop [--json] Send SIGTERM to the running daemon. + start Start the registered per-user service. + run [--dry-run] Internal foreground service-manager dispatcher entry. + stop [--json] Stop the registered per-user service. --json emits hive-daemon-stop.v1. - status [--json] Show running / not-running. + status [--json] Show process and service-manager state. reload [--json] Send SIGHUP to reload config. --json emits hive-daemon-reload.v1. tail Stream daemon.log. @@ -505,6 +557,10 @@ module Hive projects; for projects that pre-date the daemon, run `hive daemon enable ` (or `--all`). + Public start, stop, and status route through launchd or `systemd --user` + when registered, with direct process control only as an unregistered + compatibility fallback. + Exit codes: 0 success; 1 daemon-not-running — `reload` exits 1 when no daemon is up (caller MUST start one first); `stop` is idempotent and exits 0 in the same condition (re-running stop is always safe); @@ -595,7 +651,7 @@ module Hive --json emits hive-bot-reload.v1. tail Stream bot.log. - The bot reads the global `bot:` block from ~/Dev/hive/config.yml. + The bot reads the global `bot:` block from $XDG_CONFIG_HOME/hive/config.yml. Its Telegram token comes only from HIVE_TELEGRAM_BOT_TOKEN. Incoming updates from chat IDs outside bot.chat_id_allowlist are ignored. diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index f88bca6f..b59a6906 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -27,7 +27,9 @@ module Hive class Daemon include Hive::Schemas::EnvelopeEmitter - VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable].freeze + # `run` is the service-manager entry point (LaunchAgent / systemd): + # foreground, non-detached, identical to `start` without --detach. + VALID_SUBCOMMANDS = %w[start run stop status reload tail enable disable].freeze # USAGE-class error specific to enable/disable. Carries an # error_kind drawn from Hive::Schemas::EnrollErrorKind so the @@ -44,7 +46,8 @@ module Hive end def initialize(subcommand, target = nil, detach: false, dry_run: false, - all: false, json: false, hive_home: Hive::Config.hive_home) + all: false, json: false, hive_home: Hive::Config.hive_home, + service_adapter: :auto) @subcommand = subcommand @target = target @detach = detach @@ -52,6 +55,7 @@ module Hive @all = all @json = json @hive_home = hive_home + @service_adapter = service_adapter end def call @@ -62,8 +66,9 @@ module Hive end case @subcommand - when "start" then start_daemon - when "stop" then stop_daemon + when "start" then start_managed_daemon + when "run" then start_daemon_foreground + when "stop" then stop_managed_daemon when "status" then status_daemon when "reload" then reload_daemon when "tail" then tail_daemon @@ -81,6 +86,25 @@ module Hive private + def start_daemon_foreground + @detach = false + start_daemon + end + + def start_managed_daemon + adapter = registered_service_adapter + return start_daemon unless adapter + + if @detach || @dry_run + raise Hive::InvalidTaskPath, + "hive daemon start is managed by the registered user service; " \ + "--detach/--dry-run apply only to the internal `daemon run` entry point" + end + raise Hive::Error, "hive: service manager did not start the daemon" unless adapter.start! + + puts "hive: daemon started via #{adapter.status['manager']}" + end + def start_daemon warn_unsupported_json_flag if @json FileUtils.mkdir_p(@hive_home) @@ -123,7 +147,7 @@ module Hive # process that happens to have the same PID. PR-40 review P2 #3. File.write(pid_file, pid_file_payload(Process.pid, own_start_time).to_yaml) - # Load the daemon block from ~/Dev/hive/config.yml so operator + # Load the daemon block from the active XDG global config so operator # overrides (max_concurrent_runs, poll_interval_sec, log paths, # etc.) actually take effect. PR-40 review P1 #2: this used to # call merge_defaults({}) which discarded the global config. @@ -260,6 +284,20 @@ module Hive end end + def stop_managed_daemon + adapter = registered_service_adapter + return stop_daemon unless adapter + + before = adapter.status + raise Hive::Error, "hive: service manager did not stop the daemon" unless adapter.stop! + + if @json + puts JSON.generate(stop_envelope(running: false, was_running: !!before["running"])) + else + puts "hive: daemon stopped via #{adapter.status['manager']}" + end + end + def status_daemon running = false pid = nil @@ -275,8 +313,17 @@ module Hive end end + service = service_manager_status + if service && service["registered"] + running = !!service["running"] + unless running + pid = nil + uptime_sec = nil + end + end + if @json - puts JSON.generate( + payload = { "schema" => "hive-daemon-status", "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), "ok" => true, @@ -284,17 +331,45 @@ module Hive "pid" => running ? pid : nil, "uptime_sec" => uptime_sec, "pid_file" => pid_file, - "log_file" => log_file - ) + "log_file" => log_file, + "service" => service + } + puts JSON.generate(payload) elsif running puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)" + puts "hive daemon service: #{service.inspect}" if service else puts "hive daemon: not running" + puts "hive daemon service: #{service.inspect}" if service end # Exit code: 0 for running, 1 for not running (per plan U8) raise Hive::Error, "daemon not running" unless running end + def service_manager_status + adapter = service_adapter + adapter&.status + rescue LoadError, StandardError + nil + end + + def registered_service_adapter + adapter = service_adapter + return nil unless adapter + + status = adapter.status + status["registered"] ? adapter : nil + end + + def service_adapter + return @service_adapter unless @service_adapter == :auto + + require "hive/service/adapter" + @service_adapter = Hive::Service::Adapter.for_host + rescue LoadError + @service_adapter = nil + end + def reload_daemon result = compute_reload_outcome if @json @@ -450,7 +525,7 @@ module Hive if @subcommand == "enable" && !@all && any_changed puts "" puts " next: hive daemon reload # if the daemon is already running" - puts " hive daemon start --dry-run --detach # if it's not" + puts " hive daemon start # if it's not" end end end diff --git a/lib/hive/commands/forget.rb b/lib/hive/commands/forget.rb index d2fbd014..e07145eb 100644 --- a/lib/hive/commands/forget.rb +++ b/lib/hive/commands/forget.rb @@ -4,7 +4,7 @@ require "hive/config" module Hive module Commands # `hive forget NAME [--json]` — remove the entry whose `name` matches - # NAME from the global registry (~/Dev/hive/config.yml). The + # NAME from the active XDG global registry. The # project's `.hive-state` directory on disk is not touched; the # registry and the on-disk state are independent. # diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb index b86e8d61..83379a89 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -5,11 +5,16 @@ require "hive/config" require "hive/git_ops" require "hive/commands/init/prompts" require "hive/commands/doctor" +require "hive/project/scaffold" +require "hive/state_migration" +require "hive/user_config" +require "hive/paths" module Hive module Commands class Init - def initialize(project_path, force: false, prompts: nil) + def initialize(project_path, force: false, prompts: nil, skip_service: false, + provider: nil, model: nil, api_key_env: nil) @project_path = File.expand_path(project_path) @force = force # Optional Prompts instance for testability. Tests inject a @@ -19,35 +24,91 @@ module Hive # summary_io: $stdout)` runs (UI on stderr, machine-parseable # summary on stdout — see #collect_prompt_answers below). @prompts = prompts + # When true, skip LaunchAgent/systemd registration (U3 hooks this). + # Kept so unit tests of the scaffold path stay isolated from the + # service manager. + @skip_service = skip_service + @provider = provider + @model = model + @api_key_env = api_key_env end def call 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}" - end + validate_clean_tree! unless @force || ops.hive_state_branch_exists? - # Prompt placement is load-bearing (per ADR-023): runs AFTER the - # already-initialized guard above, BEFORE any disk writes below. + # Prompt placement is load-bearing (per ADR-023): runs BEFORE any + # project disk writes below. A repair run only prompts when the + # per-project config is actually missing. # An aborted prompt (`n` at confirmation) leaves zero footprint — # no orphan branch, no worktree, no master .gitignore update — # so a re-run of `hive init` proceeds normally. - answers = collect_prompt_answers + answers = collect_prompt_answers unless project_config_exists?(ops) + + ensure_user_config! + maybe_migrate_legacy_state! ops.hive_state_init write_per_project_config(ops, answers: answers) ops.add_hive_state_to_master_gitignore! + scaffold_project! entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path) + register_user_service_if_needed! + print_summary(entry: entry, ops: ops) run_init_preflight! end + def scaffold_project! + Hive::Project::Scaffold.new(@project_path).call(force: @force) + end + + def ensure_user_config! + Hive::UserConfig.ensure_configured!( + provider: @provider, + model: @model, + api_key_env: @api_key_env + ) + rescue Hive::ConfigError => e + warn "hive: user config: #{e.message}" + raise + end + + def maybe_migrate_legacy_state! + migration = Hive::StateMigration.new + return unless migration.legacy_present? + + result = migration.call(copy: true) + return unless result.migrated + + warn "hive: migrated #{result.files.size} file(s) from legacy state " \ + "into #{result.destination} (source retained at #{result.source})" + end + + def register_user_service_if_needed! + return if @skip_service + + # U3 wires LaunchAgent / systemd --user here. Until the adapters + # load, init remains functional without native service registration. + begin + require "hive/service/adapter" + rescue LoadError + return + end + return unless defined?(Hive::Service::Adapter) + + adapter = Hive::Service::Adapter.for_host + return unless adapter + + adapter.register! + adapter.prompt_start_if_interactive! + rescue NotImplementedError + nil + end + # Non-fatal skill preflight: after init succeeds, run the doctor # against the freshly-written config and emit stderr warnings for # any `:missing` rows. Init's exit code is unaffected — install @@ -166,10 +227,16 @@ module Hive cfg_path = File.join(ops.hive_state_path, "config.yml") return if File.exist?(cfg_path) + raise Hive::ConfigError, "project config is missing and no initialization answers were collected" unless answers + content = render_project_config(ops, answers: answers) File.write(cfg_path, content) end + def project_config_exists?(ops) + File.exist?(File.join(ops.hive_state_path, "config.yml")) + end + def render_project_config(ops, answers:) require "erb" template = File.read(File.expand_path("../../../templates/project_config.yml.erb", __dir__)) diff --git a/lib/hive/commands/prune.rb b/lib/hive/commands/prune.rb index c91f02d7..5fc0ca52 100644 --- a/lib/hive/commands/prune.rb +++ b/lib/hive/commands/prune.rb @@ -4,7 +4,7 @@ require "hive/config" module Hive module Commands # `hive prune [--dry-run] [--json]` — drop every registry entry in - # ~/Dev/hive/config.yml whose `path` no longer points at a directory. + # active XDG global config whose `path` no longer points at a directory. # Also drops malformed entries (non-Hash rows, rows missing `path`, # rows whose `path` isn't a String) — these are hand-edit accidents # and have always been undisplayable in `hive status`. The diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb new file mode 100644 index 00000000..8ecc7dc3 --- /dev/null +++ b/lib/hive/commands/uninstall.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "hive/install_provenance" +require "hive/project/cleanup" +require "hive/service/adapter" +require "fileutils" + +module Hive + module Commands + # Safe uninstall (R15 / F4 / AE4): stop+unregister service, delegate + # binary removal to the owning channel, optionally clean hash-matching + # generated files in the *current* project only. Config, XDG state, + # completed outputs, and agent skills are preserved by default. + class Uninstall + def initialize( + project_path: Dir.pwd, + yes: false, + clean_project: false, + dry_run: false, + command_path: nil, + exec: method(:system), + service_adapter: :auto, + input: $stdin, + output: $stdout + ) + @project_path = File.expand_path(project_path) + @yes = yes + @clean_project = clean_project + @dry_run = dry_run + @command_path = command_path + @exec = exec + @service_adapter = service_adapter + @input = input + @output = output + end + + def call + owner = InstallProvenance.detect(command_path: @command_path) + cleanup = Hive::Project::Cleanup.new(@project_path) + plan = cleanup.preview + + print_preview(owner, plan) + return preview_only(owner, plan) if @dry_run + return preview_only(owner, plan) unless confirm? + + ensure_owned!(owner) + + stop_and_unregister_service! + remove_binary!(owner) + + if @clean_project + result = cleanup.call!(confirm: true) + @output.puts "hive uninstall: removed #{result.removed.size} generated file(s); " \ + "preserved #{result.preserved.size} modified/unknown" + else + @output.puts "hive uninstall: left project files intact " \ + "(pass --clean-project to remove unmodified generated scaffold only)" + end + + @output.puts "hive uninstall: preserved user config, XDG state, and agent skills" + owner + end + + private + + def print_preview(owner, plan) + @output.puts "hive uninstall preview:" + @output.puts " channel: #{owner.channel}" + @output.puts " binary action: #{owner.uninstaller.inspect || 'manual'}" + @output.puts " service: stop + unregister" + @output.puts " project removable (hash-match): #{plan.removed.inspect}" + @output.puts " project preserved (modified): #{plan.preserved.inspect}" + @output.puts " always preserved: $XDG_CONFIG_HOME/hive, $XDG_STATE_HOME/hive, skills" + end + + def preview_only(owner, _plan) + @output.puts "hive uninstall: no changes made (preview/cancelled)" + owner + end + + def confirm? + return true if @yes + return false unless @input.respond_to?(:tty?) && @input.tty? + + @output.print "hive uninstall: proceed? [y/N] " + %w[y yes].include?(@input.gets.to_s.strip.downcase) + end + + def stop_and_unregister_service! + adapter = service_adapter + return unless adapter + + raise Hive::Error, "hive uninstall: service manager failed to stop the daemon" unless adapter.stop! + raise Hive::Error, "hive uninstall: service manager failed to unregister the daemon" unless adapter.unregister! + rescue StandardError => e + raise e if e.is_a?(Hive::Error) + + raise Hive::Error, "hive uninstall: service cleanup failed: #{e.message}" + end + + def remove_binary!(owner) + case owner.channel + when :bash + unlink_bash(owner) + else + cmd = owner.uninstaller + return unless cmd.is_a?(Array) + + ok = @exec.call(*cmd) + unless ok + raise Hive::Error, + "hive uninstall: package manager failed (#{cmd.inspect}); " \ + "not continuing into project cleanup" + end + end + end + + def unlink_bash(owner) + receipt = owner.details + links = Array(receipt["command_paths"]) + raise Hive::Error, "hive uninstall: Bash receipt has no owned command paths" if links.empty? + + links.each do |link| + unless InstallProvenance.bash_receipt_owns_path?(receipt, link) + raise Hive::Error, + "hive uninstall: #{link} no longer matches the installed Bash payload; " \ + "refusing to remove a replacement command" + end + end + links.each { |link| FileUtils.rm_f(link) } + FileUtils.rm_f(InstallProvenance.receipt_path) + @output.puts "hive uninstall: removed #{links.join(', ')}" + end + + def ensure_owned!(owner) + return unless %i[unknown ambiguous].include?(owner.channel) + + raise Hive::Error, + "hive uninstall: #{owner.details['hint']} No service, project, or binary changes were made." + end + + def service_adapter + return @service_adapter unless @service_adapter == :auto + + @service_adapter = Hive::Service::Adapter.for_host + end + end + end +end diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb new file mode 100644 index 00000000..49400a8d --- /dev/null +++ b/lib/hive/commands/update.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "open3" +require "hive/install_provenance" +require "hive/service/adapter" + +module Hive + module Commands + # `hive update` delegates to the owning install channel (R8 / F3). + class Update + def initialize(command_path: nil, dry_run: false, exec: method(:system), + service_adapter: :auto, version_probe: nil) + @command_path = command_path + @dry_run = dry_run + @exec = exec + @service_adapter = service_adapter + @version_probe = version_probe || method(:probe_version) + end + + def call + owner = InstallProvenance.detect(command_path: @command_path) + case owner.channel + when :unknown, :ambiguous + warn "hive update: #{owner.details['hint']}" + raise Hive::Error, "cannot update: install ownership is #{owner.channel}" + end + + unless owner.updater + raise Hive::Error, "hive update: no updater for channel #{owner.channel}" + end + + puts "hive update: channel=#{owner.channel} via #{owner.updater.inspect}" + if @dry_run + puts "hive update: dry-run; not executing" + return owner + end + + # Capture service state so we can refresh without changing + # autostart after the package manager swaps the binary. + adapter, was_registered, was_enabled, was_running = service_state + + ok = @exec.call(*owner.updater) + unless ok + raise Hive::Error, + "hive update: package manager failed (#{owner.updater.inspect}); " \ + "not modifying service registration or project files" + end + + refresh_service(adapter, was_registered, was_enabled, was_running) + resulting_version = @version_probe.call(owner.command_path) + unless resulting_version.to_s.match?(/\A[0-9]+\.[0-9]+\.[0-9]+\z/) + raise Hive::Error, + "hive update: installed command failed version verification at #{owner.command_path}" + end + puts "hive update: done; version=#{resulting_version}" + owner + end + + private + + def service_state + adapter = service_adapter + return [ nil, false, false, false ] unless adapter + + st = adapter.status + [ adapter, !!st["registered"], !!st["enabled"], !!st["running"] ] + end + + def refresh_service(adapter, was_registered, was_enabled, was_running) + return unless adapter && was_registered + + adapter.refresh!(was_enabled: was_enabled, was_running: was_running) + refreshed = adapter.status + return if !!refreshed["enabled"] == was_enabled && !!refreshed["running"] == was_running + + raise Hive::Error, + "hive update: service refresh did not preserve state " \ + "(enabled #{was_enabled}->#{refreshed['enabled']}, " \ + "running #{was_running}->#{refreshed['running']})" + rescue StandardError => e + raise e if e.is_a?(Hive::Error) + + raise Hive::Error, "hive update: service refresh failed: #{e.message}" + end + + def service_adapter + return @service_adapter unless @service_adapter == :auto + + @service_adapter = Hive::Service::Adapter.for_host + end + + def probe_version(path) + out, err, status = Open3.capture3(path, "--version") + raise Hive::Error, "hive update: #{path} --version failed: #{err.strip}" unless status.success? + + out.strip + end + end + end +end diff --git a/lib/hive/config.rb b/lib/hive/config.rb index a67097d8..ca8bbcf6 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 @@ -158,7 +159,7 @@ module Hive }, # Global Telegram bot settings. The bot is an operator surface # across every registered project, so runtime code loads these - # from ~/Dev/hive/config.yml via load_global_bot. The token lives + # from the XDG global config via load_global_bot. The token lives # only in HIVE_TELEGRAM_BOT_TOKEN and is never persisted. "bot" => { "enabled" => false, @@ -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" => "~/.local/state/hive/.bot.pid", + "log_file" => "~/.local/state/hive/logs/bot.log", "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" => "~/.local/state/hive/.bot.last_seen_update_id" }, # Auto-rebase pre-step for `hive run` (plan # docs/plans/2026-05-14-001-feat-hive-auto-rebase-stale-worktree-plan.md). @@ -209,11 +210,13 @@ module Hive module_function def hive_home - ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive") + # HIVE_HOME override → XDG state home. Legacy data is adopted separately. + # See Hive::Paths (KTD3 / R9). + Hive::Paths.hive_home end def global_config_path - File.join(hive_home, "config.yml") + Hive::Paths.global_registry_path end def hive_state_dir(project_root, hive_state_name = ".hive-state") @@ -299,7 +302,7 @@ module Hive # same `error_kind: "config"` envelope the TUI's narrow rescue catches. # # EACCES specifically is the most user-facing of the IO group: - # `chmod 000 ~/Dev/hive/config.yml` (or running as a different + # `chmod 000 $XDG_CONFIG_HOME/hive/config.yml` (or running as a different # user) used to surface as `internal error: Errno::EACCES: ...` # at exit 70. The root cause is configuration access, not a Hive # bug — exit 78 is the right shape. @@ -311,7 +314,7 @@ module Hive raise ConfigError, "global config at #{path} is not readable: #{e.message}" end - # Atomic + EACCES-aware writer for ~/Dev/hive/config.yml. Mirrors + # Atomic + EACCES-aware writer for the active XDG global config. Mirrors # the shape of `Hive::Markers.write_atomic` so a future flock # upgrade (Issue #31) can swap in here without rewriting every # call site. Permission errors on write surface as ConfigError @@ -327,7 +330,7 @@ module Hive end # Load and validate the global `daemon` block from - # `~/Dev/hive/config.yml`. Returns the merged Hash (operator + # `$XDG_CONFIG_HOME/hive/config.yml`. 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 +358,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 active XDG 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. diff --git a/lib/hive/daemon/child_supervisor.rb b/lib/hive/daemon/child_supervisor.rb index fd87d15a..bb7e53b2 100644 --- a/lib/hive/daemon/child_supervisor.rb +++ b/lib/hive/daemon/child_supervisor.rb @@ -73,7 +73,7 @@ module Hive FileUtils.mkdir_p(File.dirname(log_path)) # Open with truncation ("w") so each child run starts a fresh # per-task log; logs from prior runs are not preserved here — - # the daemon's own log file at ~/Dev/hive/logs/daemon.log + # the daemon's own log file under $XDG_STATE_HOME/hive/logs # carries the cross-run history. log_io = File.open(log_path, "w") log_io.puts("[hive-daemon] #{Time.now.utc.iso8601} spawn argv=#{argv.inspect}") diff --git a/lib/hive/daemon/dispatcher.rb b/lib/hive/daemon/dispatcher.rb index 4f6d3450..e82bdb54 100644 --- a/lib/hive/daemon/dispatcher.rb +++ b/lib/hive/daemon/dispatcher.rb @@ -513,7 +513,7 @@ module Hive end def reload_config! - # PR-40 review P1 #2: rebase on the global ~/Dev/hive/config.yml's + # PR-40 review P1 #2: rebase on the active XDG global config's # daemon block, not bare DEFAULTS. @daemon_cfg = Hive::Config.load_global_daemon @config = { "daemon" => @daemon_cfg } diff --git a/lib/hive/dependency_check.rb b/lib/hive/dependency_check.rb new file mode 100644 index 00000000..36fea04b --- /dev/null +++ b/lib/hive/dependency_check.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "open3" +require "rbconfig" + +module Hive + # Classifies host tools as hard, channel-specific, or optional and + # reports missing tools with actionable install hints (R7). Never + # silently installs anything. + module DependencyCheck + Check = Struct.new(:name, :kind, :capability, :present, :path, :hint, keyword_init: true) + + HARD = [ + { name: "git", capability: "project_workflow", hint: "install git via your package manager (e.g. apt install git / brew install git)" }, + { name: "bash", capability: "project_workflow", hint: "install bash (required for shell hooks and installers)" } + ].freeze + + CHANNEL = [ + { name: "curl", capability: "bash_install", hint: "install curl to use the Bash installer channel" }, + { name: "tar", capability: "bash_install", hint: "install tar to extract release archives" }, + { name: "sha256sum", capability: "bash_install", hint: "install coreutils (sha256sum) or use shasum -a 256 on macOS", + alternatives: %w[shasum] } + ].freeze + + OPTIONAL = [ + { name: "gh", capability: "github_integration", hint: "install GitHub CLI (https://cli.github.com) for PR and attestation features" }, + { name: "jq", capability: "json_tooling", hint: "install jq for shell-side JSON processing of hive --json output" }, + { name: "claude", capability: "agent_claude", hint: "install Claude Code CLI for planning/review agents" }, + { name: "codex", capability: "agent_codex", hint: "install Codex CLI for the recommended execute agent" }, + { name: "pi", capability: "agent_pi", hint: "install Pi CLI for the pi agent profile" }, + { name: "brew", capability: "channel_homebrew", hint: "install Homebrew on macOS for the native package channel" }, + { name: "systemctl", capability: "daemon_systemd", hint: "systemd --user is required for native Linux daemon registration" }, + { name: "launchctl", capability: "daemon_launchd", hint: "launchctl is required for macOS LaunchAgent registration" } + ].freeze + + module_function + + def check_all(path_env: ENV["PATH"]) + (HARD + CHANNEL + OPTIONAL).map { |spec| check_one(spec, path_env: path_env) } + end + + def hard_missing(path_env: ENV["PATH"]) + check_all(path_env: path_env).select { |c| c.kind == :hard && !c.present } + end + + def report(io: $stderr, path_env: ENV["PATH"]) + results = check_all(path_env: path_env) + missing = results.reject(&:present) + return if missing.empty? + + io.puts "hive: dependency diagnostics" + missing.each do |c| + io.puts " [#{c.kind}/#{c.capability}] missing #{c.name}: #{c.hint}" + end + end + + def which(cmd, path_env: ENV["PATH"]) + path_env.to_s.split(File::PATH_SEPARATOR).each do |dir| + candidate = File.join(dir, cmd) + return candidate if File.executable?(candidate) + end + nil + end + + def check_one(spec, path_env: ENV["PATH"]) + names = [spec[:name]] + Array(spec[:alternatives]) + found = nil + names.each do |n| + found = which(n, path_env: path_env) + break if found + end + + kind = if HARD.any? { |h| h[:name] == spec[:name] } + :hard + elsif CHANNEL.any? { |h| h[:name] == spec[:name] } + :channel + else + :optional + end + + Check.new( + name: spec[:name], + kind: kind, + capability: spec[:capability], + present: !found.nil?, + path: found, + hint: spec[:hint] + ) + end + private_class_method :check_one + end +end diff --git a/lib/hive/install_provenance.rb b/lib/hive/install_provenance.rb new file mode 100644 index 00000000..fd9868b3 --- /dev/null +++ b/lib/hive/install_provenance.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "open3" +require "json" +require "yaml" +require "digest" +require "hive/paths" +require "hive/assets" + +module Hive + # Detect which install channel owns the running binary (KTD5 / R8). + # Never mutates package-manager files; returns a structured owner for + # `hive update` / `hive uninstall` to delegate to. + module InstallProvenance + Owner = Struct.new(:channel, :command_path, :details, :updater, :uninstaller, keyword_init: true) + + RECEIPT_NAME = "install-receipt.yml" + HOMEBREW_FORMULA = "ivankuznetsov/hive/hive" + RELEASE_REPOSITORY = "ivankuznetsov/hive" + + module_function + + def detect(command_path: nil) + path = command_path || resolve_self_path + path = File.expand_path(path) if path + + owners = [] + owners << detect_homebrew(path) if path + owners << detect_aur(path) if path + owners << detect_bash(path) + owners.compact! + + case owners.size + when 0 + Owner.new( + channel: :unknown, + command_path: path, + details: { "hint" => "Could not determine install channel. " \ + "Reinstall via brew, AUR hive-bin, or the Bash installer." }, + updater: nil, + uninstaller: nil + ) + when 1 + owners.first + else + Owner.new( + channel: :ambiguous, + command_path: path, + details: { + "candidates" => owners.map(&:channel), + "hint" => "Multiple install channels claim this binary. " \ + "Use the channel you installed with: brew upgrade, " \ + "yay -Syu hive-bin, or re-run install.sh." + }, + updater: nil, + uninstaller: nil + ) + end + end + + def receipt_path + File.join(Hive::Paths.data_home, RECEIPT_NAME) + end + + def write_bash_receipt!(version:, command_name:, prefix:, binary_path:, command_names: nil, bin_dir: Hive::Paths.bin_dir) + names = Array(command_names || [ command_name ]).map(&:to_s).uniq + paths = names.map { |name| File.join(bin_dir, name) } + raise ArgumentError, "bash receipt binary must exist: #{binary_path}" unless File.file?(binary_path) + + data = { + "channel" => "bash", + "version" => version, + "command_name" => command_name, + "command_names" => names, + "command_paths" => paths, + "bin_dir" => File.expand_path(bin_dir), + "prefix" => prefix, + "binary_path" => File.expand_path(binary_path), + "binary_sha256" => Digest::SHA256.file(binary_path).hexdigest, + "installed_at" => Time.now.utc.iso8601 + } + Hive::Paths.write_private_file(receipt_path, data.to_yaml) + receipt_path + end + + def read_bash_receipt + return nil unless File.exist?(receipt_path) + + data = YAML.safe_load(File.read(receipt_path)) || {} + return nil unless data.is_a?(Hash) && data["channel"] == "bash" + + data + end + + def resolve_self_path + # $0 may be bin/hive or a symlink in PATH. + return File.expand_path($PROGRAM_NAME) if $PROGRAM_NAME && File.exist?($PROGRAM_NAME) + + which = `command -v hive 2>/dev/null`.strip + which.empty? ? nil : which + end + private_class_method :resolve_self_path + + def detect_homebrew(path) + brew = `command -v brew 2>/dev/null`.strip + return nil if brew.empty? + + return nil unless path_owned_by_brew_formula?(path, HOMEBREW_FORMULA) + + Owner.new( + channel: :homebrew, + command_path: path, + details: { "formula" => HOMEBREW_FORMULA }, + updater: [ "brew", "upgrade", HOMEBREW_FORMULA ], + uninstaller: [ "brew", "uninstall", HOMEBREW_FORMULA ] + ) + end + private_class_method :detect_homebrew + + def path_owned_by_brew_formula?(path, formula) + out, _err, status = Open3.capture3("brew", "--prefix", formula) + return false unless status.success? + + prefix = out.strip + return false if prefix.empty? + + resolved_path = File.realpath(path) + resolved_prefix = File.realpath(prefix) + resolved_path == resolved_prefix || resolved_path.start_with?(resolved_prefix + File::SEPARATOR) + rescue StandardError + false + end + private_class_method :path_owned_by_brew_formula? + + def detect_aur(path) + # pacman -Qo /usr/bin/hive + return nil unless File.exist?("/usr/bin/pacman") + + out, _err, status = Open3.capture3("pacman", "-Qo", path) + return nil unless status.success? + return nil unless out.match?(/is owned by hive-bin\b/) + + helper = %w[yay paru pamac].map { |h| `command -v #{h} 2>/dev/null`.strip }.find { |h| !h.empty? } + updater = if helper + [ File.basename(helper), "-Syu", "hive-bin" ] + else + %w[pacman -Syu hive-bin] + end + Owner.new( + channel: :aur, + command_path: path, + details: { "package" => "hive-bin", "helper" => helper }, + updater: updater, + uninstaller: helper ? [ File.basename(helper), "-R", "hive-bin" ] : %w[pacman -R hive-bin] + ) + rescue StandardError + nil + end + private_class_method :detect_aur + + def detect_bash(path) + receipt = read_bash_receipt + return nil unless receipt + return nil unless path && bash_receipt_owns_path?(receipt, path) + + command_name = receipt["command_name"] || "hive" + prefix = receipt.fetch("prefix") + updater = [ + Hive::Assets.resolve("lib", "hive", "scripts", "bash_update.sh"), + "--repo", RELEASE_REPOSITORY, + "--command-name", command_name, + "--prefix", prefix + ] + Owner.new( + channel: :bash, + command_path: File.expand_path(path), + details: receipt, + updater: updater, + uninstaller: [ :bash_unlink, *Array(receipt["command_paths"]) ] + ) + end + private_class_method :detect_bash + + def bash_receipt_owns_path?(receipt, path) + return false unless receipt.is_a?(Hash) && receipt["channel"] == "bash" + + version = receipt["version"].to_s + return false unless version.match?(/\A[0-9]+\.[0-9]+\.[0-9]+\z/) + + prefix = receipt["prefix"].to_s + binary = receipt["binary_path"].to_s + digest = receipt["binary_sha256"].to_s + command_paths = Array(receipt["command_paths"]).map { |candidate| File.expand_path(candidate.to_s) } + requested = File.expand_path(path) + return false unless Hive::Paths.absolute_path?(prefix) && Hive::Paths.absolute_path?(binary) + return false unless command_paths.include?(requested) + return false unless File.file?(binary) && !File.symlink?(binary) + return false unless digest.match?(/\A[a-f0-9]{64}\z/) + return false unless Digest::SHA256.file(binary).hexdigest == digest + + versions_root = File.join(File.expand_path(prefix), "versions", version) + return false unless binary == versions_root || binary.start_with?(versions_root + File::SEPARATOR) + return false unless File.exist?(requested) || File.symlink?(requested) + + File.realpath(requested) == File.realpath(binary) + rescue Errno::ENOENT, Errno::EACCES, ArgumentError + false + end + end +end diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb new file mode 100644 index 00000000..f536c95a --- /dev/null +++ b/lib/hive/paths.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "fileutils" +require "tmpdir" + +module Hive + # XDG Base Directory resolution for Hive runtime paths (KTD3 / R9). + # + # Order of preference for each base: + # 1. Absolute path in the matching XDG_* environment variable + # 2. Documented default under $HOME + # + # Relative XDG values are rejected: they would write relative to the + # current working directory and violate the standards contract. + # + # HIVE_HOME remains a supported override for the combined state/config + # home used by the daemon and global registry, so existing tests and + # operators keep working while the XDG layout is adopted. + module Paths + APP_NAME = "hive" + DIR_MODE = 0o700 + FILE_MODE = 0o600 + + module_function + + def config_home + ensure_dir(join_base(xdg_or_default("XDG_CONFIG_HOME", File.join(home, ".config")), APP_NAME)) + end + + def data_home + ensure_dir(join_base(xdg_or_default("XDG_DATA_HOME", File.join(home, ".local", "share")), APP_NAME)) + end + + def state_home + ensure_dir(join_base(xdg_or_default("XDG_STATE_HOME", File.join(home, ".local", "state")), APP_NAME)) + end + + def cache_home + ensure_dir(join_base(xdg_or_default("XDG_CACHE_HOME", File.join(home, ".cache")), APP_NAME)) + end + + def runtime_dir + ensure_dir(resolve_runtime_dir) + end + + def versions_dir + ensure_dir(File.join(data_home, "versions")) + end + + def version_dir(version = Hive::VERSION) + ensure_dir(File.join(versions_dir, version.to_s)) + end + + def user_config_path + # Keep test/operator HIVE_HOME isolation for user config too so + # `ensure_defaults!` during `hive init` never writes into the real + # home when a temporary HIVE_HOME is set. + if hive_home_override + File.join(hive_home_override, "user-config.yml") + else + File.join(config_home, "config.yml") + end + end + + def global_registry_path + # Prefer HIVE_HOME when set (test/compat override); otherwise XDG config. + if hive_home_override + File.join(hive_home_override, "config.yml") + else + File.join(config_home, "config.yml") + end + end + + # Combined "hive home" used by daemon PID/log paths. Prefer explicit + # HIVE_HOME, otherwise use the active XDG state root. Legacy ~/Dev/hive + # data is adopted by StateMigration rather than becoming the live path. + def hive_home + return hive_home_override if hive_home_override + + state_home + end + + def legacy_global_home + File.expand_path("~/Dev/hive") + end + + def legacy_state_home + File.expand_path("~/.hive-state") + end + + def bin_dir + ensure_dir(xdg_or_default("XDG_BIN_HOME", File.join(home, ".local", "bin"))) + end + + def ensure_dir(path, mode: DIR_MODE) + FileUtils.mkdir_p(path) + File.chmod(mode, path) if File.directory?(path) + path + end + + def write_private_file(path, content) + dir = File.dirname(path) + ensure_dir(dir) + tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp") + File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, FILE_MODE) do |f| + f.write(content) + f.fsync + end + File.rename(tmp, path) + File.chmod(FILE_MODE, path) + path + ensure + FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp) + end + + def absolute_env_path(name) + raw = ENV[name] + return nil if raw.nil? || raw.strip.empty? + return File.expand_path(raw) if absolute_path?(raw) + + raise Hive::ConfigError, + "#{name}=#{raw.inspect} must be an absolute path " \ + "(XDG Base Directory Specification); refusing to write relative to the project" + end + + def absolute_path?(path) + path.start_with?("/") || (path.match?(/\A[A-Za-z]:[\\\/]/) && Gem.win_platform?) + end + + def home + File.expand_path("~") + end + + def hive_home_override + raw = ENV["HIVE_HOME"] + return nil if raw.nil? || raw.strip.empty? + + File.expand_path(raw) + end + private_class_method :hive_home_override + + def xdg_or_default(env_name, default) + absolute_env_path(env_name) || default + end + private_class_method :xdg_or_default + + def join_base(base, name) + File.join(base, name) + end + private_class_method :join_base + + def resolve_runtime_dir + if (from_env = absolute_env_path("XDG_RUNTIME_DIR")) + return File.join(from_env, APP_NAME) + end + + # macOS and other hosts without XDG_RUNTIME_DIR: private 0700 dir + # under the platform temp root (KTD3). + base = File.join(Dir.tmpdir, "hive-runtime-#{Process.uid}") + FileUtils.mkdir_p(base) + File.chmod(0o700, base) + unless File.stat(base).uid == Process.uid + warn "hive: runtime directory #{base} is not owned by the current user; " \ + "sockets/locks may be less isolated than XDG_RUNTIME_DIR" + end + File.join(base, APP_NAME) + end + private_class_method :resolve_runtime_dir + end +end diff --git a/lib/hive/platform.rb b/lib/hive/platform.rb new file mode 100644 index 00000000..9950cb99 --- /dev/null +++ b/lib/hive/platform.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Hive + # Host capability and tier classification (R19). + # + # Tier-1: macOS 14+ arm64, Ubuntu 22.04+ x86_64, current Arch x86_64 + # Tier-2: best-effort (other Linux/macOS combinations) + # Tier-3: rejected with a clear support message + module Platform + Tier = Struct.new(:level, :os, :arch, :message, keyword_init: true) + + module_function + + def uname_s + `uname -s 2>/dev/null`.strip + rescue StandardError + RbConfig::CONFIG["host_os"].to_s + end + + def uname_m + `uname -m 2>/dev/null`.strip + rescue StandardError + RbConfig::CONFIG["host_cpu"].to_s + end + + def os_family + s = uname_s.downcase + return :macos if s.include?("darwin") + return :linux if s.include?("linux") + return :windows if s.include?("mingw") || s.include?("msys") || s.include?("cygwin") + + :unknown + end + + def arch + m = uname_m.downcase + case m + when "arm64", "aarch64" then :arm64 + when "x86_64", "amd64" then :x86_64 + else m.empty? ? :unknown : m.to_sym + end + end + + def macos? + os_family == :macos + end + + def linux? + os_family == :linux + end + + def linux_distro + return nil unless linux? + + if File.exist?("/etc/os-release") + data = File.read("/etc/os-release") + id = data[/^ID=(.+)$/, 1]&.gsub(/["']/, "") + return id + end + nil + end + + def linux_version + return nil unless linux? + return nil unless File.exist?("/etc/os-release") + + data = File.read("/etc/os-release") + data[/^VERSION_ID=(.+)$/, 1]&.gsub(/["']/, "") + rescue StandardError + nil + end + + def macos_major + return nil unless macos? + + ver = `sw_vers -productVersion 2>/dev/null`.strip + ver.split(".").first.to_i + rescue StandardError + nil + end + + def classify + case os_family + when :macos + if arch == :arm64 && macos_major && macos_major >= 14 + Tier.new(level: 1, os: :macos, arch: :arm64, message: "macOS arm64 (tier-1)") + elsif arch == :arm64 || arch == :x86_64 + Tier.new(level: 2, os: :macos, arch: arch, message: "macOS #{arch} (tier-2 best-effort)") + else + Tier.new(level: 3, os: :macos, arch: arch, message: "unsupported macOS architecture: #{arch}") + end + when :linux + distro = linux_distro + ubuntu_supported = distro == "ubuntu" && version_at_least?(linux_version, "22.04") + if arch == :x86_64 && (distro == "arch" || ubuntu_supported) + Tier.new(level: 1, os: :linux, arch: :x86_64, message: "Linux #{distro} x86_64 (tier-1)") + elsif arch == :x86_64 || arch == :arm64 + Tier.new(level: 2, os: :linux, arch: arch, message: "Linux #{distro || 'unknown'} #{arch} (tier-2 best-effort)") + else + Tier.new(level: 3, os: :linux, arch: arch, message: "unsupported Linux architecture: #{arch}") + end + when :windows + Tier.new(level: 3, os: :windows, arch: arch, message: "Windows is not supported in v1; use WSL2 with a tier-1 Linux distro") + else + Tier.new(level: 3, os: os_family, arch: arch, message: "unsupported operating system: #{uname_s.inspect}") + end + end + + def tier1? + classify.level == 1 + end + + def supported? + classify.level <= 2 + end + + def reject_if_unsupported! + tier = classify + return tier if tier.level <= 2 + + raise Hive::Error, "hive: #{tier.message}. See docs/install.md for the support matrix." + end + + def release_asset_triplet + case [ os_family, arch ] + when [ :macos, :arm64 ] then "macos-arm64" + when [ :linux, :x86_64 ] then "linux-x86_64" + else + "#{os_family}-#{arch}" + end + end + + def version_at_least?(actual, minimum) + return false if actual.nil? || actual.empty? + + require "rubygems/version" + Gem::Version.new(actual) >= Gem::Version.new(minimum) + rescue ArgumentError + false + end + private_class_method :version_at_least? + end +end diff --git a/lib/hive/project/cleanup.rb b/lib/hive/project/cleanup.rb new file mode 100644 index 00000000..58083e9b --- /dev/null +++ b/lib/hive/project/cleanup.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "fileutils" +require "hive/project/manifest" + +module Hive + module Project + # Hash-verified cleanup of manifest-owned files in the *current* + # project only (R15, KTD8). Never walks the filesystem for other + # projects; never deletes modified or unknown content. + class Cleanup + Result = Struct.new(:removed, :preserved, :skipped_unknown, :preview, keyword_init: true) + + def initialize(project_root) + @project_root = File.expand_path(project_root) + @manifest = Manifest.load(@project_root) + end + + def preview + return empty_result(preview: true) unless @manifest + + Result.new( + removed: @manifest.removable_paths, + preserved: @manifest.preserved_paths, + skipped_unknown: unknown_under_hive, + preview: true + ) + end + + def call!(confirm: false) + plan = preview + return plan unless confirm + + removed = [] + plan.removed.each do |rel| + abs = File.join(@project_root, rel) + next unless safe_path?(abs) + + FileUtils.rm_f(abs) + removed << rel + end + + # Remove empty .hive/ if only the manifest remains and it was ours. + hive_dir = File.join(@project_root, ".hive") + if File.directory?(hive_dir) + leftovers = Dir.children(hive_dir) - %w[. ..] + if leftovers.empty? || leftovers == ["manifest.yml"] + FileUtils.rm_rf(hive_dir) if plan.preserved.empty? + end + end + + Result.new( + removed: removed, + preserved: plan.preserved, + skipped_unknown: plan.skipped_unknown, + preview: false + ) + end + + private + + def empty_result(preview:) + Result.new(removed: [], preserved: [], skipped_unknown: [], preview: preview) + end + + def unknown_under_hive + hive = File.join(@project_root, ".hive") + return [] unless File.directory?(hive) + return [] unless @manifest + + known = @manifest.generated.keys.map { |r| File.join(@project_root, r) } + known << @manifest.path + found = [] + Dir.glob(File.join(hive, "**", "*"), File::FNM_DOTMATCH).each do |abs| + next if File.directory?(abs) + next if known.include?(abs) + + found << abs.delete_prefix(@project_root + "/") + end + found + end + + def safe_path?(abs) + return false if File.symlink?(abs) + + real = begin + File.realpath(File.dirname(abs)) + rescue Errno::ENOENT + return false + end + root = File.realpath(@project_root) + real == root || real.start_with?(root + File::SEPARATOR) + end + end + end +end diff --git a/lib/hive/project/manifest.rb b/lib/hive/project/manifest.rb new file mode 100644 index 00000000..7eb521be --- /dev/null +++ b/lib/hive/project/manifest.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "digest" +require "yaml" +require "fileutils" +require "hive/paths" + +module Hive + module Project + # Ownership manifest for generated project files (KTD8 / R10 / R15). + # Uninstall offers deletion only for unmodified manifest-owned paths. + class Manifest + FILENAME = "manifest.yml" + SCHEMA_VERSION = 1 + SHIPPED_GENERATED_PATHS = %w[ + .hive/README.md + .hive/config.example.yml + ].freeze + + attr_reader :root, :data + + def self.path_for(project_root) + File.join(project_root, ".hive", FILENAME) + end + + def self.load(project_root) + path = path_for(project_root) + return nil unless File.exist?(path) + + data = YAML.safe_load(File.read(path)) || {} + raise Hive::ConfigError, "manifest at #{path} must be a hash" unless data.is_a?(Hash) + + new(project_root, data) + end + + def self.exists?(project_root) + File.exist?(path_for(project_root)) + end + + def initialize(project_root, data = nil) + @root = File.expand_path(project_root) + @data = data || { + "schema_version" => SCHEMA_VERSION, + "template_version" => Hive::VERSION, + "generated" => {}, + "created_at" => Time.now.utc.iso8601, + "updated_at" => Time.now.utc.iso8601 + } + end + + def path + self.class.path_for(@root) + end + + def template_version + @data["template_version"] + end + + def generated + @data["generated"] ||= {} + end + + def record!(relative_path, content) + rel = relative_path.to_s.sub(%r{\A\./}, "") + digest = Digest::SHA256.hexdigest(content) + generated[rel] = { + "sha256" => digest, + "bytes" => content.bytesize + } + @data["updated_at"] = Time.now.utc.iso8601 + digest + end + + def save! + Hive::Paths.ensure_dir(File.dirname(path)) + Hive::Paths.write_private_file(path, @data.to_yaml) + path + end + + def unmodified?(relative_path) + rel = relative_path.to_s.sub(%r{\A\./}, "") + entry = generated[rel] + return false unless entry + + abs = File.join(@root, rel) + return false unless File.file?(abs) + return false if File.symlink?(abs) + + Digest::SHA256.file(abs).hexdigest == entry["sha256"] + end + + def removable_paths + generated.keys.select { |rel| shipped_generated_path?(rel) && unmodified?(rel) } + end + + def preserved_paths + generated.keys.reject { |rel| shipped_generated_path?(rel) && unmodified?(rel) } + end + + def shipped_generated_path?(relative_path) + SHIPPED_GENERATED_PATHS.include?(relative_path.to_s.sub(%r{\A\./}, "")) + end + end + end +end diff --git a/lib/hive/project/scaffold.rb b/lib/hive/project/scaffold.rb new file mode 100644 index 00000000..0003ead9 --- /dev/null +++ b/lib/hive/project/scaffold.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "fileutils" +require "erb" +require "hive/project/manifest" +require "hive/paths" +require "hive/assets" + +module Hive + module Project + # Writes the versioned `.hive/` scaffold used for ownership tracking and + # package-safe cleanup. Does NOT replace `.hive-state/` (the git worktree + # state machine) — both coexist: `.hive-state` is runtime state; `.hive` + # is the install/uninstall ownership boundary (R10, KTD8). + class Scaffold + TEMPLATE_FILES = { + "README.md" => <<~MD, + # Hive project scaffold + + This directory is managed by `hive init`. It records ownership + metadata for files Hive generated so `hive uninstall` can remove + only unmodified generated content. + + Project pipeline state lives in `.hive-state/` (git worktree). + Do not store secrets here. + MD + "config.example.yml" => <<~YAML + # Example project-local overrides. Real credentials must stay in + # environment variables — never paste secrets into YAML. + # + # provider: openai + # model: gpt-5 + # api_key_env: OPENAI_API_KEY + YAML + }.freeze + + unless TEMPLATE_FILES.keys.map { |rel| File.join(".hive", rel) }.sort == Manifest::SHIPPED_GENERATED_PATHS.sort + raise "Hive project scaffold and cleanup allowlist are out of sync" + end + + Result = Struct.new(:created, :skipped, :conflicts, :manifest, keyword_init: true) + + def initialize(project_root) + @project_root = File.expand_path(project_root) + @hive_dir = File.join(@project_root, ".hive") + end + + def call(force: false) + if File.exist?(@hive_dir) && !Manifest.exists?(@project_root) && !force + raise Hive::Error, + "hive: #{@hive_dir} exists without a Hive manifest; " \ + "refusing to claim ownership. Move or remove it, or re-run with --force" + end + + manifest = Manifest.load(@project_root) || Manifest.new(@project_root) + created = [] + skipped = [] + conflicts = [] + + TEMPLATE_FILES.each do |rel, content| + target_rel = File.join(".hive", rel) + abs = File.join(@project_root, target_rel) + if File.exist?(abs) + if manifest.generated[target_rel] && !manifest.unmodified?(target_rel) + conflicts << target_rel + skipped << target_rel + next + end + if File.read(abs) == content + skipped << target_rel + manifest.record!(target_rel, content) unless manifest.generated[target_rel] + next + end + # Existing file differs and is not tracked as ours → preserve. + if manifest.generated[target_rel] + conflicts << target_rel + end + skipped << target_rel + next + end + + FileUtils.mkdir_p(File.dirname(abs)) + File.write(abs, content) + File.chmod(0o644, abs) + manifest.record!(target_rel, content) + created << target_rel + end + + # Always refresh manifest metadata even when no files changed. + manifest.data["template_version"] ||= Hive::VERSION + manifest.save! + + Result.new(created: created, skipped: skipped, conflicts: conflicts, manifest: manifest) + end + end + end +end diff --git a/lib/hive/scripts/bash_update.sh b/lib/hive/scripts/bash_update.sh new file mode 100755 index 00000000..39b7c3f5 --- /dev/null +++ b/lib/hive/scripts/bash_update.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Resolve the latest semver tag, then download, checksum, and execute that +# immutable release's installer. The mutable /latest endpoint is used only +# to discover a tag; no bytes from it are executed. +set -euo pipefail + +repo="ivankuznetsov/hive" +command_name="hive" +prefix="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) repo=${2:?missing repository}; shift 2 ;; + --command-name) command_name=${2:?missing command name}; shift 2 ;; + --prefix) prefix=${2:?missing prefix}; shift 2 ;; + *) echo "error: unknown bash updater option: $1" >&2; exit 64 ;; + esac +done + +case "$command_name" in hive|hv) ;; *) echo "error: invalid command name" >&2; exit 64 ;; esac + +for tool in curl bash; do + command -v "$tool" >/dev/null 2>&1 || { echo "error: missing required tool '$tool'" >&2; exit 1; } +done + +if command -v sha256sum >/dev/null 2>&1; then + sha256_file() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; } +else + echo "error: need sha256sum or shasum for installer verification" >&2 + exit 1 +fi + +if [[ -n "${HIVE_UPDATE_VERSION:-}" ]]; then + version=$HIVE_UPDATE_VERSION +else + effective=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${repo}/releases/latest") + tag=${effective##*/tag/} + version=${tag#v} +fi +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: latest release did not resolve to a semver tag: ${version}" >&2 + exit 1 +fi + +base="https://github.com/${repo}/releases/download/v${version}" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +curl -fsSL "${base}/install.sh" -o "${tmp}/install.sh" +curl -fsSL "${base}/SHA256SUMS" -o "${tmp}/SHA256SUMS" +expected=$(awk '$2=="install.sh" {print $1}' "${tmp}/SHA256SUMS") +actual=$(sha256_file "${tmp}/install.sh") +if [[ -z "$expected" || "$expected" != "$actual" ]]; then + echo "error: SHA-256 mismatch for pinned installer v${version}" >&2 + exit 1 +fi + +if command -v gh >/dev/null 2>&1; then + gh attestation verify "${tmp}/install.sh" --repo "$repo" >/dev/null +fi + +args=(--version "$version" --command-name "$command_name") +[[ -n "$prefix" ]] && args+=(--prefix "$prefix") +exec bash "${tmp}/install.sh" "${args[@]}" diff --git a/lib/hive/service/adapter.rb b/lib/hive/service/adapter.rb new file mode 100644 index 00000000..f72aeaa9 --- /dev/null +++ b/lib/hive/service/adapter.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "erb" +require "fileutils" +require "open3" +require "hive/paths" +require "hive/platform" + +module Hive + module Service + # Normalized per-user service contract (KTD7 / R13-R14). + # Platform adapters render and register definitions; enable/start is + # never implicit on non-interactive sessions. + class Adapter + LABEL = "dev.hive.daemon" + SYSTEMD_UNIT = "hive.service" + + Contract = Struct.new( + :command_path, :state_dir, :runtime_dir, :log_path, :env, + keyword_init: true + ) + + attr_reader :contract + + def self.for_host(contract: nil) + c = contract || default_contract + case Hive::Platform.os_family + when :macos then require "hive/service/launchd"; Launchd.new(c) + when :linux then require "hive/service/systemd_user"; SystemdUser.new(c) + end + end + + def self.default_contract + command = resolve_command_path + state = Hive::Paths.state_home + Contract.new( + command_path: command, + state_dir: state, + runtime_dir: Hive::Paths.runtime_dir, + log_path: File.join(state, "daemon.log"), + env: build_env + ) + end + + def self.resolve_command_path + # Prefer the stable invoked path (symlink), never realpath of a + # versioned cellar path (risk: service breaks after upgrade). + from_env = ENV["HIVE_COMMAND_PATH"] + return File.expand_path(from_env) if from_env && !from_env.empty? + + candidates = [ + ($PROGRAM_NAME if $PROGRAM_NAME && File.executable?($PROGRAM_NAME)), + `command -v hv 2>/dev/null`.strip, + `command -v hive 2>/dev/null`.strip, + File.expand_path("../../../bin/hive", __dir__) + ].compact.uniq + candidates.find { |candidate| hive_command?(candidate) } || + raise(Hive::Error, "hive: could not resolve a verified hive/hv command for service registration") + end + + def self.hive_command?(candidate) + return false if candidate.empty? || !File.executable?(candidate) + + out, _err, status = Open3.capture3(candidate, "--version") + status.success? && out.strip == Hive::VERSION + rescue StandardError + false + end + + def self.build_env + env = {} + env["HIVE_HOME"] = Hive::Paths.hive_home + %w[XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR].each do |k| + env[k] = ENV[k] if ENV[k] && !ENV[k].empty? + end + env + end + + def initialize(contract) + @contract = contract + end + + def register! + raise NotImplementedError + end + + def unregister! + raise NotImplementedError + end + + def start! + raise NotImplementedError + end + + def stop! + raise NotImplementedError + end + + def status + raise NotImplementedError + end + + def enabled? + raise NotImplementedError + end + + def definition_path + raise NotImplementedError + end + + def render + raise NotImplementedError + end + + def prompt_start_if_interactive!(input: $stdin, output: $stderr) + if input.respond_to?(:tty?) && input.tty? + output.print "hive: start the background daemon now? [y/N] " + answer = input.gets.to_s.strip.downcase + if %w[y yes].include?(answer) + started = start! + raise Hive::Error, "hive: service manager did not start the daemon" unless started + + output.puts "hive: daemon started" + return :started + end + output.puts "hive: daemon registered but not started " \ + "(run `hive daemon start` when ready)" + :declined + else + output.puts "hive: service registered (not started; non-interactive). " \ + "Start later with: hive daemon start" + :non_interactive + end + end + + def refresh!(was_enabled:, was_running:) + register! + raise Hive::Error, "hive: service manager did not restart the daemon" if was_running && !start! + # Preserve enablement separately from running state. + if was_enabled && respond_to?(:enable!) && !enable! + raise Hive::Error, "hive: service manager did not restore daemon enablement" + end + true + end + + protected + + def render_template(name, binding_obj) + path = File.expand_path("../../../templates/service/#{name}", __dir__) + ERB.new(File.read(path), trim_mode: "-").result(binding_obj) + end + + def write_definition(path, content) + FileUtils.mkdir_p(File.dirname(path)) + tmp = "#{path}.tmp.#{Process.pid}" + File.write(tmp, content) + File.chmod(0o644, tmp) + File.rename(tmp, path) + path + ensure + FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp) + end + end + end +end diff --git a/lib/hive/service/launchd.rb b/lib/hive/service/launchd.rb new file mode 100644 index 00000000..d09eb741 --- /dev/null +++ b/lib/hive/service/launchd.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "open3" +require "hive/service/adapter" + +module Hive + module Service + class Launchd < Adapter + def definition_path + File.join(File.expand_path("~/Library/LaunchAgents"), "#{LABEL}.plist") + end + + def render + label = LABEL + command_path = contract.command_path + state_dir = contract.state_dir + log_path = contract.log_path + env = contract.env + render_template("dev.hive.daemon.plist.erb", binding) + end + + def register! + write_definition(definition_path, render) + # bootout first so re-register is idempotent; ignore failures. + system("launchctl", "bootout", "gui/#{Process.uid}", definition_path, out: File::NULL, err: File::NULL) + ok = system("launchctl", "bootstrap", "gui/#{Process.uid}", definition_path, out: File::NULL, err: File::NULL) + raise Hive::Error, "hive: launchctl bootstrap failed for #{definition_path}" unless ok || definition_loaded? + + definition_path + end + + def unregister! + if File.exist?(definition_path) || definition_loaded? + ok = system("launchctl", "bootout", "gui/#{Process.uid}", definition_path, + out: File::NULL, err: File::NULL) + if !ok && definition_loaded? + raise Hive::Error, "hive: launchctl bootout failed for #{definition_path}" + end + end + FileUtils.rm_f(definition_path) + true + end + + def start! + ok = system("launchctl", "kickstart", "-k", "gui/#{Process.uid}/#{LABEL}", + out: File::NULL, err: File::NULL) + raise Hive::Error, "hive: launchctl kickstart failed for #{LABEL}" unless ok + + true + end + + def stop! + return true unless definition_loaded? + + ok = system("launchctl", "kill", "SIGTERM", "gui/#{Process.uid}/#{LABEL}", + out: File::NULL, err: File::NULL) + raise Hive::Error, "hive: launchctl stop failed for #{LABEL}" unless ok + + true + end + + def enable! + # RunAtLoad stays false in the plist; enablement is "registered". + true + end + + def enabled? + File.exist?(definition_path) + end + + def status + out, _err, status = Open3.capture3("launchctl", "print", "gui/#{Process.uid}/#{LABEL}") + running = status.success? && out.match?(/state = running/i) + { + "manager" => "launchd", + "registered" => File.exist?(definition_path), + "enabled" => enabled?, + "running" => running, + "definition" => definition_path, + "label" => LABEL + } + end + + private + + def definition_loaded? + _out, _err, status = Open3.capture3("launchctl", "print", "gui/#{Process.uid}/#{LABEL}") + status.success? + end + end + end +end diff --git a/lib/hive/service/systemd_user.rb b/lib/hive/service/systemd_user.rb new file mode 100644 index 00000000..4a90f110 --- /dev/null +++ b/lib/hive/service/systemd_user.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "open3" +require "hive/service/adapter" + +module Hive + module Service + class SystemdUser < Adapter + def definition_path + unit_dir = ENV["XDG_CONFIG_HOME"] + unit_dir = if unit_dir && !unit_dir.empty? && unit_dir.start_with?("/") + File.join(unit_dir, "systemd", "user") + else + File.expand_path("~/.config/systemd/user") + end + File.join(unit_dir, SYSTEMD_UNIT) + end + + def render + command_path = contract.command_path + state_dir = contract.state_dir + log_path = contract.log_path + env = contract.env + render_template("hive.service.erb", binding) + end + + def systemd_escape(value) + value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("%", "%%") + end + + def register! + write_definition(definition_path, render) + unless systemd_available? + warn "hive: systemctl --user is unavailable; wrote unit to #{definition_path}. " \ + "Start on demand with: hive daemon start" + return definition_path + end + + run_systemctl!("daemon-reload") + definition_path + end + + def unregister! + return true unless File.exist?(definition_path) + + if systemd_available? + run_systemctl!("disable", "--now", SYSTEMD_UNIT) + end + FileUtils.rm_f(definition_path) + run_systemctl!("daemon-reload") if systemd_available? + true + end + + def start! + unless systemd_available? + raise Hive::Error, + "hive: systemctl --user is unavailable (no reachable user bus); " \ + "the unit remains registered at #{definition_path}" + end + run_systemctl!("enable", SYSTEMD_UNIT) + run_systemctl!("start", SYSTEMD_UNIT) + true + end + + def stop! + return true unless File.exist?(definition_path) + return true unless systemd_available? + + run_systemctl!("stop", SYSTEMD_UNIT) + end + + def enable! + return false unless systemd_available? + + run_systemctl!("enable", SYSTEMD_UNIT) + end + + def enabled? + return File.exist?(definition_path) unless systemd_available? + + out, _err, status = Open3.capture3("systemctl", "--user", "is-enabled", SYSTEMD_UNIT) + status.success? && out.strip == "enabled" + end + + def status + running = false + if systemd_available? + _out, _err, st = Open3.capture3("systemctl", "--user", "is-active", SYSTEMD_UNIT) + running = st.success? + end + { + "manager" => "systemd-user", + "registered" => File.exist?(definition_path), + "enabled" => enabled?, + "running" => running, + "definition" => definition_path, + "unit" => SYSTEMD_UNIT, + "systemd_available" => systemd_available? + } + end + + private + + def systemd_available? + return @systemd_available if defined?(@systemd_available) + + @systemd_available = begin + _out, _err, status = Open3.capture3("systemctl", "--user", "show-environment") + status.success? + rescue StandardError + false + end + end + + def run_systemctl!(*args) + ok = system("systemctl", "--user", *args, out: File::NULL, err: File::NULL) + raise Hive::Error, "hive: systemctl --user #{args.join(' ')} failed" unless ok + + true + end + end + end +end diff --git a/lib/hive/state_migration.rb b/lib/hive/state_migration.rb new file mode 100644 index 00000000..4e7a149a --- /dev/null +++ b/lib/hive/state_migration.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "fileutils" +require "digest" +require "yaml" +require "hive/paths" + +module Hive + # Non-destructive migration of legacy global state into the XDG layout + # (R12, KTD3). Copies and verifies; never deletes the legacy tree. + class StateMigration + Result = Struct.new(:migrated, :source, :destination, :files, :skipped_reason, keyword_init: true) + + def initialize( + legacy_global: Hive::Paths.legacy_global_home, + legacy_state: Hive::Paths.legacy_state_home, + destination: nil, + config_destination: nil + ) + @legacy_global = legacy_global + @legacy_state = legacy_state + @destination = destination || Hive::Paths.state_home + @config_destination = config_destination || Hive::Paths.global_registry_path + end + + def legacy_present? + File.directory?(@legacy_global) || File.directory?(@legacy_state) + end + + def call(copy: true) + sources = [] + sources << @legacy_global if File.directory?(@legacy_global) + sources << @legacy_state if File.directory?(@legacy_state) + + if sources.empty? + return Result.new( + migrated: false, + source: nil, + destination: @destination, + files: [], + skipped_reason: "no legacy state found" + ) + end + + + adopt_legacy_config! if copy + + marker = File.join(@destination, ".migration.yml") + if File.exist?(marker) + return Result.new( + migrated: false, + source: sources.first, + destination: @destination, + files: [], + skipped_reason: "migration already recorded at #{marker}" + ) + end + + return dry_result(sources) unless copy + + copied = [] + sources.each do |src| + label = File.basename(src) + dest_root = File.join(@destination, "legacy-import", label) + FileUtils.mkdir_p(dest_root) + Dir.chdir(src) do + Dir.glob("**/*", File::FNM_DOTMATCH).each do |rel| + next if rel == "." || rel == ".." + next if File.directory?(rel) + + target = File.join(dest_root, rel) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp(rel, target) + unless File.size(target) == File.size(rel) && + Digest::SHA256.file(target).hexdigest == Digest::SHA256.file(rel).hexdigest + raise Hive::Error, "hive: migration verify failed for #{rel}" + end + copied << File.join("legacy-import", label, rel) + end + end + end + + record = { + "migrated_at" => Time.now.utc.iso8601, + "sources" => sources, + "files" => copied, + "legacy_retained" => true + } + Hive::Paths.write_private_file(marker, record.to_yaml) + + Result.new( + migrated: true, + source: sources.join(","), + destination: @destination, + files: copied, + skipped_reason: nil + ) + end + + private + + def adopt_legacy_config! + source = File.join(@legacy_global, "config.yml") + return unless File.file?(source) + return if File.expand_path(source) == File.expand_path(@config_destination) + + legacy = YAML.safe_load(File.read(source)) || {} + current = if File.exist?(@config_destination) + YAML.safe_load(File.read(@config_destination)) || {} + else + {} + end + unless legacy.is_a?(Hash) && current.is_a?(Hash) + raise Hive::ConfigError, "legacy and active global config must both be hashes" + end + + merged = legacy.merge(current) + legacy_projects = Array(legacy["registered_projects"]) + current_projects = Array(current["registered_projects"]) + merged["registered_projects"] = (current_projects + legacy_projects).uniq do |entry| + entry.is_a?(Hash) ? [ entry["name"], entry["path"] ] : entry + end + Hive::Paths.write_private_file(@config_destination, merged.to_yaml) + rescue Psych::Exception => e + raise Hive::ConfigError, "legacy config at #{source} is not valid YAML: #{e.message}" + end + + def dry_result(sources) + Result.new( + migrated: false, + source: sources.join(","), + destination: @destination, + files: [], + skipped_reason: "copy disabled" + ) + end + end +end diff --git a/lib/hive/user_config.rb b/lib/hive/user_config.rb new file mode 100644 index 00000000..45c61af2 --- /dev/null +++ b/lib/hive/user_config.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "yaml" +require "stringio" +require "hive/paths" + +module Hive + # User-scoped provider/model configuration at $XDG_CONFIG_HOME/hive/config.yml + # (R10, R11, KTD6). Stores credential *references* (env var names), never + # plaintext secrets. + module UserConfig + SECRET_VALUE_PATTERN = / + \b(sk-[A-Za-z0-9_-]{10,}| + xox[baprs]-[A-Za-z0-9-]{10,}| + gh[pousr]_[A-Za-z0-9]{20,})\b + /x + + module_function + + def path + Hive::Paths.user_config_path + end + + def load + return {} unless File.exist?(path) + + data = YAML.safe_load(File.read(path)) || {} + raise Hive::ConfigError, "user config at #{path} must be a hash" unless data.is_a?(Hash) + + data + rescue Psych::Exception => e + raise Hive::ConfigError, "user config at #{path} is not valid YAML: #{e.message}" + end + + def write!(data) + raise ArgumentError, "user config must be a Hash" unless data.is_a?(Hash) + + # Reject secrets before sanitize/redact so a caller that tries to + # persist a raw key fails loudly rather than writing [REDACTED]. + if SECRET_VALUE_PATTERN.match?(data.to_yaml) || data.keys.map(&:to_s).intersect?(%w[api_key token secret password]) + raise Hive::ConfigError, + "refusing to write user config: payload appears to contain a plaintext secret; " \ + "store an environment-variable reference (e.g. api_key_env: OPENAI_API_KEY) instead" + end + sanitized = sanitize(data) + payload = sanitized.to_yaml + Hive::Paths.write_private_file(path, payload) + path + end + + ENV_REFERENCE_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/ + + def ensure_configured!(provider: nil, model: nil, api_key_env: nil, input: $stdin, output: $stderr) + data = load + return path if %w[provider model api_key_env].all? { |key| present?(data[key]) } + + values = { + "provider" => provider || ENV["HIVE_PROVIDER"] || data["provider"], + "model" => model || ENV["HIVE_MODEL"] || data["model"], + "api_key_env" => api_key_env || ENV["HIVE_API_KEY_ENV"] || data["api_key_env"] + } + + if values.values.any? { |value| !present?(value) } + if input.respond_to?(:tty?) && input.tty? + values = prompt_for_missing(values, input: input, output: output) + else + raise Hive::ConfigError, + "first-run provider configuration is required; pass --provider NAME " \ + "--model NAME --api-key-env ENV_VAR (or set HIVE_PROVIDER, HIVE_MODEL, " \ + "and HIVE_API_KEY_ENV), then re-run hive init" + end + end + + unless ENV_REFERENCE_PATTERN.match?(values.fetch("api_key_env")) + raise Hive::ConfigError, + "api_key_env must be an environment-variable name such as OPENAI_API_KEY; " \ + "plaintext credentials are never stored" + end + + %w[provider model api_key_env].each do |key| + data[key] = values.fetch(key) unless present?(data[key]) + end + data["created_at"] ||= Time.now.utc.iso8601 + write!(data) + end + + # Backward-compatible API for callers that already provide explicit + # values. It no longer invents provider/model/credential defaults. + def ensure_defaults!(provider: nil, model: nil, api_key_env: nil) + ensure_configured!( + provider: provider, + model: model, + api_key_env: api_key_env, + input: StringIO.new + ) + end + + def redact(text) + text.to_s.gsub(SECRET_VALUE_PATTERN, "[REDACTED]") + end + + def sanitize(data) + data.each_with_object({}) do |(k, v), out| + key = k.to_s + if %w[api_key token secret password].include?(key) + # Drop raw secret keys entirely — only *_env references are allowed. + next + end + + out[key] = case v + when Hash then sanitize(v) + when String then redact(v) + else v + end + end + end + private_class_method :sanitize + + def present?(value) + value.is_a?(String) && !value.strip.empty? + end + private_class_method :present? + + def prompt_for_missing(values, input:, output:) + prompts = { + "provider" => "Provider (for example openai or anthropic): ", + "model" => "Model (for example gpt-5): ", + "api_key_env" => "Credential environment variable (for example OPENAI_API_KEY): " + } + output.puts "Hive provider setup stores references only; do not paste an API key." + prompts.each do |key, label| + next if present?(values[key]) + + loop do + output.print label + output.flush + answer = input.gets + raise Hive::ConfigError, "provider setup ended before #{key} was supplied; no config was written" if answer.nil? + + answer = answer.strip + next if answer.empty? + + values[key] = answer + break + end + end + values + end + private_class_method :prompt_for_missing + end +end diff --git a/packaging/aur/PKGBUILD.erb b/packaging/aur/PKGBUILD.erb new file mode 100644 index 00000000..d3c72324 --- /dev/null +++ b/packaging/aur/PKGBUILD.erb @@ -0,0 +1,24 @@ +# Maintainer: Hive contributors +pkgname=hive-bin +pkgver=<%= version %> +pkgrel=1 +pkgdesc="Multi-agent folder-as-state software pipeline (prebuilt)" +arch=('x86_64') +url="https://github.com/ivankuznetsov/hive" +license=('MIT') +provides=('hive') +conflicts=('hive') +depends=() +source=("<%= url %>") +sha256sums=('<%= sha256 %>') + +package() { + cd "$srcdir/hive" + install -Dm755 hive "$pkgdir/usr/bin/hive" + ln -s hive "$pkgdir/usr/bin/hv" + if [ -d share ]; then + mkdir -p "$pkgdir/usr/share/hive" + cp -a share/. "$pkgdir/usr/share/hive/" + fi + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 2>/dev/null || true +} diff --git a/packaging/aur/hive.install b/packaging/aur/hive.install new file mode 100644 index 00000000..f963b234 --- /dev/null +++ b/packaging/aur/hive.install @@ -0,0 +1,13 @@ +# No per-user state mutations in package hooks (KTD5). +# Service registration and XDG materialization happen inside `hive init`. +post_install() { + echo "hive-bin installed. Run 'hive init' in a project, then 'hive daemon start' if desired." +} + +post_upgrade() { + echo "hive-bin upgraded. Existing daemon enable/start state is preserved; run 'hive daemon status' to inspect." +} + +pre_remove() { + echo "hive-bin: package files will be removed. User config, state, and project .hive-state are left intact." +} diff --git a/packaging/hive-skills/.agents/plugins/marketplace.json b/packaging/hive-skills/.agents/plugins/marketplace.json new file mode 100644 index 00000000..1edfc616 --- /dev/null +++ b/packaging/hive-skills/.agents/plugins/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "hive-skills", + "interface": { + "displayName": "Hive Skills" + }, + "plugins": [ + { + "name": "hive", + "source": { + "source": "local", + "path": "./plugins/hive" + }, + "description": "Drive Hive from a coding agent", + "version": "0.1.0" + } + ] +} diff --git a/packaging/hive-skills/.claude-plugin/marketplace.json b/packaging/hive-skills/.claude-plugin/marketplace.json new file mode 100644 index 00000000..40534f61 --- /dev/null +++ b/packaging/hive-skills/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "hive-skills", + "owner": { + "name": "ivankuznetsov" + }, + "metadata": { + "description": "Hive multi-agent pipeline skills for Claude Code", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "hive", + "source": "./plugins/hive", + "description": "Drive Hive from a coding agent: init, stage verbs, status, findings" + } + ] +} diff --git a/packaging/hive-skills/.github/workflows/publish.yml b/packaging/hive-skills/.github/workflows/publish.yml new file mode 100644 index 00000000..701b76c2 --- /dev/null +++ b/packaging/hive-skills/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish hive-skills + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + id-token: write + +jobs: + validate: + uses: ./.github/workflows/validate.yml + + publish-npm: + needs: validate + runs-on: ubuntu-latest + environment: npm + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + - name: Verify tag and package version agree + working-directory: plugins/hive + run: test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME" + - run: npm pack --dry-run + working-directory: plugins/hive + - run: npm publish --access public --provenance + working-directory: plugins/hive + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packaging/hive-skills/.github/workflows/validate.yml b/packaging/hive-skills/.github/workflows/validate.yml new file mode 100644 index 00000000..98c6c59b --- /dev/null +++ b/packaging/hive-skills/.github/workflows/validate.yml @@ -0,0 +1,24 @@ +name: Validate hive-skills + +on: + push: + pull_request: + workflow_call: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + - run: ruby test/validate_manifests.rb + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: ruby test/validate_package.rb + - name: Install native agent CLIs + run: npm install --global @anthropic-ai/claude-code @openai/codex @mariozechner/pi-coding-agent + - name: Isolated native install and discovery + run: bash test/native_discovery.sh diff --git a/packaging/hive-skills/README.md b/packaging/hive-skills/README.md new file mode 100644 index 00000000..a378a38c --- /dev/null +++ b/packaging/hive-skills/README.md @@ -0,0 +1,37 @@ +# hive-skills + +Separate multi-agent skills package for Hive. Distributed independently of the core binary (R17). + +## Install + +**Claude Code** + +```bash +claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +claude plugin install hive@hive-skills +``` + +**Codex** + +```bash +codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0 +codex plugin add hive@hive-skills +``` + +**Pi** + +```bash +pi install npm:@ivankuznetsov/hive-skills@0.1.0 +``` + +## Layout + +``` +plugins/hive/ + skills/hive/SKILL.md + .claude-plugin/plugin.json + .codex-plugin/plugin.json + package.json +``` + +The shared skill content is host-neutral; only the manifests differ per agent. diff --git a/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json new file mode 100644 index 00000000..2e8a8557 --- /dev/null +++ b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "hive", + "version": "0.1.0", + "description": "Hive multi-agent folder-as-state pipeline skill", + "author": { + "name": "Hive contributors" + } +} diff --git a/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json new file mode 100644 index 00000000..4df90f74 --- /dev/null +++ b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "hive", + "version": "0.1.0", + "description": "Hive multi-agent folder-as-state pipeline skill", + "skills": ["skills/hive"] +} diff --git a/packaging/hive-skills/plugins/hive/package.json b/packaging/hive-skills/plugins/hive/package.json new file mode 100644 index 00000000..4e0c603e --- /dev/null +++ b/packaging/hive-skills/plugins/hive/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ivankuznetsov/hive-skills", + "version": "0.1.0", + "description": "Hive shared agent skill for Pi and npm distribution", + "license": "MIT", + "files": [ + "skills/**", + ".claude-plugin/**", + ".codex-plugin/**" + ], + "pi": { + "skills": ["skills/hive"] + }, + "keywords": ["hive", "agent", "skills", "pi"] +} diff --git a/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md new file mode 100644 index 00000000..0bd96374 --- /dev/null +++ b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md @@ -0,0 +1,43 @@ +--- +name: hive +description: Drive the Hive multi-agent pipeline — install verification, init, stage verbs, status, findings, and daemon lifecycle. +--- + +# Hive skill + +Use only a command whose version and help output identify this Hive product. +An unrelated `hive` (notably Apache Hive) must never win over a verified `hv`. + +## Discover the command + +```bash +is_hive_pipeline() { + command -v "$1" >/dev/null 2>&1 || return 1 + "$1" --version 2>/dev/null | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' || return 1 + "$1" help 2>/dev/null | grep -Eq 'daemon|folder-as-state|Multi-agent' || return 1 +} + +if is_hive_pipeline hive; then HIVE=hive +elif is_hive_pipeline hv; then HIVE=hv +else echo "Hive is not installed, or only an unrelated hive command was found"; exit 1 +fi +"$HIVE" --version +``` + +## Common operations + +| Intent | Command | +|--------|---------| +| Attach project | `$HIVE init .` | +| New idea | `$HIVE new ""` | +| Dashboard | `$HIVE tui` | +| Status JSON | `$HIVE status --json` | +| Advance stage | `$HIVE brainstorm\|plan\|develop\|open-pr\|review\|finalize ` | +| Findings | `$HIVE findings ` | +| Daemon | `$HIVE daemon status` / `start` / `stop` | +| Update | `$HIVE update` | +| Uninstall | `$HIVE uninstall` | + +Never write provider API secrets into YAML. Use environment-variable references only. + +See [references/commands.md](references/commands.md) for the full command surface. diff --git a/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md b/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md new file mode 100644 index 00000000..458f7b79 --- /dev/null +++ b/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md @@ -0,0 +1,33 @@ +# Hive command reference (skill) + +Resolve `HIVE` as `hive` or `hv` (whichever is installed). + +## Lifecycle + +- `$HIVE --version` +- `$HIVE init [PATH]` +- `$HIVE update` +- `$HIVE uninstall [--clean-project] [--yes]` +- `$HIVE doctor` + +## Workflow verbs + +- `$HIVE new PROJECT TEXT` +- `$HIVE brainstorm|plan|develop|open-pr|review|finalize|archive TARGET` +- `$HIVE run TARGET` +- `$HIVE status [--json] [--diagnose SLUG]` +- `$HIVE findings TARGET` +- `$HIVE accept-finding` / `$HIVE reject-finding` + +## Daemon + +- `$HIVE daemon start [--detach]` +- `$HIVE daemon run` (service entry) +- `$HIVE daemon stop|status|reload|tail` +- `$HIVE daemon enable|disable PROJECT|--all` + +## Safety + +- Do not overwrite an unrelated `hive` binary. +- Do not autostart the daemon non-interactively. +- Do not delete `$XDG_STATE_HOME/hive` or agent skills during uninstall. diff --git a/packaging/hive-skills/test/native_discovery.sh b/packaging/hive-skills/test/native_discovery.sh new file mode 100755 index 00000000..77a4befe --- /dev/null +++ b/packaging/hive-skills/test/native_discovery.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Exercise each agent's own package lifecycle in isolated config roots. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export CLAUDE_CONFIG_DIR="$tmp/claude" +claude plugin validate "$root" +claude plugin marketplace add "$root" +claude plugin install hive@hive-skills +claude plugin list | grep -q 'hive' + +export CODEX_HOME="$tmp/codex" +codex plugin marketplace add "$root" +codex plugin add hive@hive-skills +codex plugin list | grep -q 'hive' + +tarball=$(cd "$root/plugins/hive" && npm pack --silent) +tarball="$root/plugins/hive/$tarball" +export PI_CODING_AGENT_DIR="$tmp/pi" +pi install "$tarball" +pi list | grep -q 'hive-skills' + +printf 'native_discovery: claude, codex, and pi discovered hive\n' diff --git a/packaging/hive-skills/test/validate_manifests.rb b/packaging/hive-skills/test/validate_manifests.rb new file mode 100755 index 00000000..4010d492 --- /dev/null +++ b/packaging/hive-skills/test/validate_manifests.rb @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "pathname" + +root = Pathname.new(__dir__).parent +errors = [] + +def load_json(path) + JSON.parse(File.read(path)) +rescue JSON::ParserError => e + raise "invalid JSON #{path}: #{e.message}" +end + +begin + claude_mkt = load_json(root.join(".claude-plugin/marketplace.json")) + codex_mkt = load_json(root.join(".agents/plugins/marketplace.json")) + claude_plug = load_json(root.join("plugins/hive/.claude-plugin/plugin.json")) + codex_plug = load_json(root.join("plugins/hive/.codex-plugin/plugin.json")) + pkg = load_json(root.join("plugins/hive/package.json")) + + versions = [ + claude_mkt.dig("metadata", "version"), + codex_mkt.dig("plugins", 0, "version"), + claude_plug["version"], + codex_plug["version"], + pkg["version"] + ] + unless versions.uniq.size == 1 + errors << "version mismatch across manifests: #{versions.inspect}" + end + + skill = root.join("plugins/hive/skills/hive/SKILL.md") + errors << "missing shared skill" unless skill.file? + body = skill.read + errors << "skill must not hardcode only 'hive' without hv fallback" unless body.include?("hv") + + puts "validate_manifests: version=#{versions.first}" +rescue StandardError => e + errors << e.message +end + +if errors.empty? + puts "ok" + exit 0 +end + +warn errors.map { |e| "error: #{e}" } +exit 1 diff --git a/packaging/hive-skills/test/validate_package.rb b/packaging/hive-skills/test/validate_package.rb new file mode 100755 index 00000000..b0a4db3b --- /dev/null +++ b/packaging/hive-skills/test/validate_package.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "open3" +require "pathname" + +root = Pathname.new(__dir__).parent +package = root.join("plugins/hive") +out, err, status = Open3.capture3("npm", "pack", "--dry-run", "--json", chdir: package.to_s) +abort "npm pack --dry-run failed: #{err}" unless status.success? + +result = JSON.parse(out).fetch(0) +files = result.fetch("files").map { |entry| entry.fetch("path") } +required = %w[ + package.json + skills/hive/SKILL.md + skills/hive/references/commands.md + .claude-plugin/plugin.json + .codex-plugin/plugin.json +] +missing = required - files +abort "npm package missing: #{missing.join(', ')}" unless missing.empty? + +forbidden = files.grep(%r{(?:^|/)(?:test|\.github|node_modules)(?:/|$)}) +abort "npm package contains repository-only files: #{forbidden.join(', ')}" unless forbidden.empty? + +puts "validate_package: #{result.fetch('filename')} contains #{files.size} files" diff --git a/packaging/homebrew/hive.rb.erb b/packaging/homebrew/hive.rb.erb new file mode 100644 index 00000000..6a7ff1d0 --- /dev/null +++ b/packaging/homebrew/hive.rb.erb @@ -0,0 +1,19 @@ +class Hive < Formula + desc "Multi-agent folder-as-state software pipeline" + homepage "https://github.com/ivankuznetsov/hive" + url "<%= url %>" + version "<%= version %>" + sha256 "<%= sha256 %>" + license "MIT" + + def install + bin.install "hive" + bin.install_symlink "hive" => "hv" + (share/"hive").install Dir["share/*"] if Dir.exist?("share") + end + + test do + assert_match version.to_s, shell_output("#{bin}/hive --version") + assert_match version.to_s, shell_output("#{bin}/hv --version") + end +end diff --git a/release/manifest.schema.json b/release/manifest.schema.json new file mode 100644 index 00000000..07f5498f --- /dev/null +++ b/release/manifest.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/release/manifest.schema.json", + "title": "Hive release manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "tag", "version", "commit", "created_at", "assets"], + "properties": { + "schema_version": { "const": 1 }, + "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "commit": { "type": "string", "minLength": 7 }, + "created_at": { "type": "string" }, + "ruby_version": { "type": "string" }, + "assets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "arch", "filename", "url", "sha256", "size", "ruby_version"], + "properties": { + "platform": { "type": "string", "enum": ["macos", "linux"] }, + "arch": { "type": "string", "enum": ["arm64", "x86_64"] }, + "filename": { "type": "string" }, + "url": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "size": { "type": "integer", "minimum": 1 }, + "ruby_version": { "type": "string" }, + "min_os": { "type": "string" }, + "min_glibc": { "type": ["string", "null"] } + } + } + } + } +} diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json index 424cc2b7..648eaf0b 100644 --- a/schemas/hive-daemon-status.v1.json +++ b/schemas/hive-daemon-status.v1.json @@ -18,7 +18,8 @@ "pid", "uptime_sec", "pid_file", - "log_file" + "log_file", + "service" ], "properties": { "schema": { "const": "hive-daemon-status" }, @@ -43,6 +44,11 @@ "log_file": { "type": "string", "description": "Absolute path of the daemon's JSON-line log file." + }, + "service": { + "type": ["object", "null"], + "description": "Per-user service-manager status (launchd / systemd --user), or null when no adapter/status is available.", + "additionalProperties": true } } } diff --git a/scripts/build-release b/scripts/build-release new file mode 100755 index 00000000..8277b386 --- /dev/null +++ b/scripts/build-release @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Build Tebako-packaged Hive artifacts for tier-1 targets. +# Release builds require Tebako. Tests may opt into a clearly-labelled +# source bundle with HIVE_ALLOW_SOURCE_BUNDLE=1; that mode is never used by +# release automation and is not a publishable artifact. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +DIST=${DIST_DIR:-"${ROOT}/dist"} +VERSION=$(ruby -I"${ROOT}/lib" -e 'require "hive"; print Hive::VERSION') +SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" show -s --format=%ct HEAD)} +if [[ ! "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then + echo "error: SOURCE_DATE_EPOCH must be an integer" >&2 + exit 1 +fi +mkdir -p "$DIST" + +platform=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$platform" in + darwin) platform=macos ;; + linux) platform=linux ;; +esac +case "$arch" in + aarch64) arch=arm64 ;; + amd64) arch=x86_64 ;; +esac + +name="hive-${platform}-${arch}" +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT + +echo "build-release: staging ${name} (version ${VERSION})" +mkdir -p "${stage}/hive" +if command -v tebako >/dev/null 2>&1; then + if ! tebako press -e "${ROOT}/bin/hive" -o "${stage}/hive/hive" \ + -r "${ROOT}" -R "$(tr -d '[:space:]' <"${ROOT}/.ruby-version")"; then + echo "error: tebako press failed; refusing to create a source fallback release" >&2 + exit 1 + fi +elif [[ "${HIVE_ALLOW_SOURCE_BUNDLE:-0}" == "1" ]]; then + echo "build-release: TEST-ONLY source-closure archive (not publishable)" + cp -a "${ROOT}/bin" "${ROOT}/lib" "${ROOT}/templates" "${ROOT}/schemas" "${stage}/hive/" + cp "${ROOT}/LICENSE" "${stage}/hive/" 2>/dev/null || true + cp "${ROOT}/bin/hive" "${stage}/hive/hive" + ruby -pi -e 'sub(%q{File.expand_path("../lib", __dir__)}, %q{File.expand_path("lib", __dir__)})' "${stage}/hive/hive" + chmod +x "${stage}/hive/hive" + # Seed tree for first-run materialization. + mkdir -p "${stage}/hive/share" + cp -a "${ROOT}/templates" "${ROOT}/schemas" "${stage}/hive/share/" 2>/dev/null || true +else + echo "error: tebako is required for release builds; install the pinned builder or set HIVE_ALLOW_SOURCE_BUNDLE=1 only for local tests" >&2 + exit 1 +fi + +printf '%s\n' "$VERSION" >"${stage}/hive/VERSION" +BUILT_AT=$(ruby -rtime -e 'print Time.at(Integer(ARGV.fetch(0))).utc.iso8601' "$SOURCE_DATE_EPOCH") +cat >"${stage}/hive/build-metadata.json" <&2 + exit 1 +fi + +TAR_BIN=${TAR:-tar} +tar_version=$("$TAR_BIN" --version 2>/dev/null || true) +if [[ "${tar_version%%$'\n'*}" != *"GNU tar"* ]]; then + echo "error: deterministic release packaging requires GNU tar (set TAR=gtar on macOS)" >&2 + exit 1 +fi +"$TAR_BIN" --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 \ + --numeric-owner --format=ustar -C "$stage" -cf - hive | gzip -n -9 >"${DIST}/${name}.tar.gz" +echo "build-release: wrote ${DIST}/${name}.tar.gz" diff --git a/scripts/generate-release-manifest b/scripts/generate-release-manifest new file mode 100755 index 00000000..1f289075 --- /dev/null +++ b/scripts/generate-release-manifest @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Generate a machine-readable release manifest from built archives. +# Usage: scripts/generate-release-manifest [base-url] +set -euo pipefail + +DIST_DIR=${1:?dist dir} +TAG=${2:?tag e.g. v0.1.0} +COMMIT=${3:?commit sha} +BASE_URL=${4:-"https://github.com/ivankuznetsov/hive/releases/download/${TAG}"} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +RUBY_VERSION=$(tr -d '[:space:]' <"${ROOT}/.ruby-version" 2>/dev/null || echo "3.4.7") +SOURCE_VERSION=$(ruby -I"${ROOT}/lib" -e 'require "hive"; print Hive::VERSION') +SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" show -s --format=%ct HEAD)} +OUT="${DIST_DIR}/manifest.json" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: tag must be vMAJOR.MINOR.PATCH, got: $TAG" >&2 + exit 1 +fi +if [[ "v${SOURCE_VERSION}" != "$TAG" ]]; then + echo "error: tag ${TAG} does not match Hive::VERSION ${SOURCE_VERSION}" >&2 + exit 1 +fi +for archive in "${DIST_DIR}"/hive-*.tar.gz; do + [[ -f "$archive" ]] || continue + packaged_version=$(tar -xOzf "$archive" hive/VERSION 2>/dev/null | tr -d '[:space:]') + if [[ "$packaged_version" != "$SOURCE_VERSION" ]]; then + echo "error: $(basename "$archive") VERSION ${packaged_version:-} does not match ${SOURCE_VERSION}" >&2 + exit 1 + fi +done +cp "${ROOT}/install.sh" "${DIST_DIR}/install.sh" + +export DIST_DIR TAG COMMIT BASE_URL RUBY_VERSION OUT SOURCE_DATE_EPOCH +ruby <<'RUBY' +require "json" +require "digest" +require "time" + +dist = ENV.fetch("DIST_DIR") +tag = ENV.fetch("TAG") +version = tag.delete_prefix("v") +commit = ENV.fetch("COMMIT") +base = ENV.fetch("BASE_URL") +ruby_ver = ENV.fetch("RUBY_VERSION") +out = ENV.fetch("OUT") + +assets = Dir[File.join(dist, "hive-*.tar.gz")].sort.filter_map do |archive| + base_name = File.basename(archive) + m = base_name.match(/\Ahive-([a-z]+)-([a-z0-9_]+)\.tar\.gz\z/) + unless m + warn "warn: skip unrecognized archive name: #{base_name}" + next + end + platform, arch = m[1], m[2] + digest = Digest::SHA256.file(archive).hexdigest + size = File.size(archive) + min_os = platform == "macos" ? "14.0" : "ubuntu-22.04" + min_glibc = platform == "linux" ? "2.31" : nil + { + "platform" => platform, + "arch" => arch, + "filename" => base_name, + "url" => "#{base}/#{base_name}", + "sha256" => digest, + "size" => size, + "ruby_version" => ruby_ver, + "min_os" => min_os, + "min_glibc" => min_glibc + } +end + +if assets.empty? + warn "error: no assets found for manifest" + exit 1 +end + +manifest = { + "schema_version" => 1, + "tag" => tag, + "version" => version, + "commit" => commit, + "created_at" => Time.at(Integer(ENV.fetch("SOURCE_DATE_EPOCH"))).utc.iso8601, + "ruby_version" => ruby_ver, + "assets" => assets +} + +blob = JSON.pretty_generate(manifest) +if blob.include?("/latest/") + warn "error: manifest must not reference mutable 'latest' assets" + exit 1 +end + +File.write(out, blob + "\n") + +sums_files = assets.map { |asset| asset["filename"] } + ["install.sh"] +sums = sums_files.sort.map do |filename| + "#{Digest::SHA256.file(File.join(dist, filename)).hexdigest} #{filename}" +end.join("\n") + "\n" +File.write(File.join(dist, "SHA256SUMS"), sums) +puts "wrote #{out} and #{File.join(dist, "SHA256SUMS")}" +RUBY diff --git a/scripts/package-release b/scripts/package-release new file mode 100755 index 00000000..adf26403 --- /dev/null +++ b/scripts/package-release @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Orchestrate build → manifest → verify for a release tag. +# Usage: scripts/package-release [commit] +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +TAG=${1:?tag e.g. v0.1.0} +COMMIT=${2:-$(git -C "$ROOT" rev-parse HEAD)} +export DIST_DIR="${DIST_DIR:-${ROOT}/dist}" +mkdir -p "$DIST_DIR" + +"${ROOT}/scripts/build-release" +"${ROOT}/scripts/generate-release-manifest" "$DIST_DIR" "$TAG" "$COMMIT" +"${ROOT}/scripts/verify-release" "$DIST_DIR" +echo "package-release: ready under ${DIST_DIR}" diff --git a/scripts/render-package-metadata b/scripts/render-package-metadata new file mode 100755 index 00000000..85409ea8 --- /dev/null +++ b/scripts/render-package-metadata @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Render Homebrew formula and AUR PKGBUILD from a release manifest. +# Usage: scripts/render-package-metadata +set -euo pipefail + +MANIFEST=${1:?manifest.json} +OUT=${2:?out dir} +mkdir -p "${OUT}/homebrew-tap/Formula" "${OUT}/aur/hive-bin" + +export MANIFEST OUT +ruby <<'RUBY' +require "json" +require "fileutils" + +manifest = JSON.parse(File.read(ENV.fetch("MANIFEST"))) +out = ENV.fetch("OUT") +version = manifest["version"] + +def find_asset(manifest, platform, arch) + manifest["assets"].find { |a| a["platform"] == platform && a["arch"] == arch } +end + +mac = find_asset(manifest, "macos", "arm64") +lin = find_asset(manifest, "linux", "x86_64") + +if mac + formula = <<~RUBY + class Hive < Formula + desc "Multi-agent folder-as-state software pipeline" + homepage "https://github.com/ivankuznetsov/hive" + url "#{mac["url"]}" + version "#{version}" + sha256 "#{mac["sha256"]}" + license "MIT" + + def install + bin.install "hive" + bin.install_symlink "hive" => "hv" + (share/"hive").install Dir["share/*"] if Dir.exist?("share") + end + + test do + assert_match version.to_s, shell_output("\#{bin}/hive --version") + assert_match version.to_s, shell_output("\#{bin}/hv --version") + end + end + RUBY + # The heredoc above escapes #{bin} as \#{bin} so Ruby leaves it for Homebrew. + formula = formula.gsub('\\#{', '#{') + path = File.join(out, "homebrew-tap", "Formula", "hive.rb") + File.write(path, formula) + puts path +end + +if lin + pkgbuild = <<~PKG + # Maintainer: Hive contributors + pkgname=hive-bin + pkgver=#{version} + pkgrel=1 + pkgdesc="Multi-agent folder-as-state software pipeline (prebuilt)" + arch=('x86_64') + url="https://github.com/ivankuznetsov/hive" + license=('MIT') + provides=('hive') + conflicts=('hive') + depends=() + source=("#{lin["url"]}") + sha256sums=('#{lin["sha256"]}') + + package() { + cd "$srcdir/hive" + install -Dm755 hive "$pkgdir/usr/bin/hive" + ln -s hive "$pkgdir/usr/bin/hv" + if [ -d share ]; then + mkdir -p "$pkgdir/usr/share/hive" + cp -a share/. "$pkgdir/usr/share/hive/" + fi + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 2>/dev/null || true + } + PKG + path = File.join(out, "aur", "hive-bin", "PKGBUILD") + File.write(path, pkgbuild) + puts path + + srcinfo = <<~SRCINFO + pkgbase = hive-bin + \tpkgdesc = Multi-agent folder-as-state software pipeline (prebuilt) + \tpkgver = #{version} + \tpkgrel = 1 + \turl = https://github.com/ivankuznetsov/hive + \tarch = x86_64 + \tlicense = MIT + \tprovides = hive + \tconflicts = hive + \tsource = #{lin["url"]} + \tsha256sums = #{lin["sha256"]} + + pkgname = hive-bin + SRCINFO + srcinfo_path = File.join(out, "aur", "hive-bin", ".SRCINFO") + File.write(srcinfo_path, srcinfo) + puts srcinfo_path +end + +puts "render-package-metadata: done" +RUBY diff --git a/scripts/test-installer-fixtures b/scripts/test-installer-fixtures new file mode 100755 index 00000000..69e3d4f3 --- /dev/null +++ b/scripts/test-installer-fixtures @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Fixture-based checks for install channel selection and hostile inputs. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +FIX="${ROOT}/test/fixtures/install_hosts" +fail=0 + +check() { + local name=$1 + shift + if "$@"; then + echo "ok - $name" + else + echo "FAIL- $name" + fail=1 + fi +} + +# install.sh is shellcheck-friendly enough to parse. +check "install.sh is bash" bash -n "${ROOT}/install.sh" + +# Dry-run on this host should not mutate. +check "install.sh dry-run" bash "${ROOT}/install.sh" --dry-run --version 0.1.0 + +# Fixture JSON files describe expected channel choices. +if [[ -d "$FIX" ]]; then + for f in "$FIX"/*.json; do + [[ -f "$f" ]] || continue + ruby -e ' + require "json" + doc = JSON.parse(File.read(ARGV[0])) + abort "bad channel" unless %w[homebrew aur bash bash_hv].include?(doc["expected_channel"]) + abort "missing os/arch" unless doc["os"] && doc["arch"] + puts "fixture ok #{ARGV[0]}" + ' "$f" + check "fixture $(basename "$f")" true + done +fi + +# Hostility: generate-release-manifest refuses empty dist. +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +if "${ROOT}/scripts/generate-release-manifest" "$tmpdir" v0.1.0 deadbeef 2>/dev/null; then + echo "FAIL- generate-release-manifest should fail on empty dist" + fail=1 +else + echo "ok - empty dist rejected" +fi + +# One-byte mutation fails verify. +mkdir -p "$tmpdir/dist" +mkdir -p "$tmpdir/archive/hive" +printf '0.1.0\n' >"$tmpdir/archive/hive/VERSION" +printf '#!/bin/sh\necho 0.1.0\n' >"$tmpdir/archive/hive/hive" +chmod +x "$tmpdir/archive/hive/hive" +tar -C "$tmpdir/archive" -czf "$tmpdir/dist/hive-linux-x86_64.tar.gz" hive +"${ROOT}/scripts/generate-release-manifest" "$tmpdir/dist" v0.1.0 abcdef1 +printf x >>"$tmpdir/dist/hive-linux-x86_64.tar.gz" +if "${ROOT}/scripts/verify-release" "$tmpdir/dist" 2>/dev/null; then + echo "FAIL- verify should fail after mutation" + fail=1 +else + echo "ok - mutation fails verify" +fi + +exit "$fail" diff --git a/scripts/verify-release b/scripts/verify-release new file mode 100755 index 00000000..15802e80 --- /dev/null +++ b/scripts/verify-release @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Verify release archives against manifest + SHA256SUMS. +# Usage: scripts/verify-release +set -euo pipefail + +DIST_DIR=${1:?dist dir} +MANIFEST="${DIST_DIR}/manifest.json" +SUMS="${DIST_DIR}/SHA256SUMS" + +if [[ ! -f "$MANIFEST" ]]; then + echo "error: missing $MANIFEST" >&2 + exit 1 +fi +if [[ ! -f "$SUMS" ]]; then + echo "error: missing $SUMS" >&2 + exit 1 +fi + +export DIST_DIR MANIFEST SUMS +ruby <<'RUBY' +require "json" +require "digest" + +dist = ENV.fetch("DIST_DIR") +manifest = JSON.parse(File.read(ENV.fetch("MANIFEST"))) +sums_path = ENV.fetch("SUMS") + +abort "error: schema_version must be 1" unless manifest["schema_version"] == 1 +abort "error: tag must start with v" unless manifest["tag"].to_s.start_with?("v") +abort "error: tag/version mismatch" unless manifest["tag"] == "v#{manifest["version"]}" + +# Verify SHA256SUMS lines +sums = {} +File.readlines(sums_path).each do |line| + line = line.strip + next if line.empty? + expected, file = line.split(/\s+/, 2) + file = file.sub(%r{\A\./}, "") + path = File.join(dist, file) + abort "error: missing #{path}" unless File.file?(path) + actual = Digest::SHA256.file(path).hexdigest + abort "error: checksum mismatch for #{file}" unless actual == expected + sums[file] = expected +end + +required_sums = manifest.fetch("assets").map { |asset| asset.fetch("filename") } + ["install.sh"] +missing_sums = required_sums - sums.keys +abort "error: SHA256SUMS missing #{missing_sums.join(', ')}" unless missing_sums.empty? + +manifest["assets"].each do |a| + abort "error: mutable latest URL in manifest: #{a["url"]}" if a["url"].include?("/latest/") + path = File.join(dist, a["filename"]) + abort "error: missing asset #{path}" unless File.file?(path) + digest = Digest::SHA256.file(path).hexdigest + abort "error: manifest sha256 mismatch for #{a["filename"]}" unless digest == a["sha256"] + abort "error: size mismatch for #{a["filename"]}" unless File.size(path) == a["size"] + packaged_version = IO.popen(["tar", "-xOzf", path, "hive/VERSION"], &:read).to_s.strip + abort "error: packaged VERSION mismatch for #{a["filename"]}" unless packaged_version == manifest["version"] +end + +puts "verify-release: ok" +RUBY diff --git a/templates/project/config.yml b/templates/project/config.yml new file mode 100644 index 00000000..019e51c7 --- /dev/null +++ b/templates/project/config.yml @@ -0,0 +1,5 @@ +# User-scoped example — real file is $XDG_CONFIG_HOME/hive/config.yml +# Credential values must never appear here; only environment references. +provider: env +model: default +api_key_env: HIVE_API_KEY diff --git a/templates/project/manifest.yml b/templates/project/manifest.yml new file mode 100644 index 00000000..892ef139 --- /dev/null +++ b/templates/project/manifest.yml @@ -0,0 +1,7 @@ +# Hive project ownership manifest scaffold (schema v1). +# Written by `hive init` via Hive::Project::Manifest. +schema_version: 1 +template_version: "0.1.0" +generated: {} +created_at: null +updated_at: null diff --git a/templates/service/dev.hive.daemon.plist.erb b/templates/service/dev.hive.daemon.plist.erb new file mode 100644 index 00000000..68d4e26b --- /dev/null +++ b/templates/service/dev.hive.daemon.plist.erb @@ -0,0 +1,33 @@ + + + + + Label + <%= label %> + ProgramArguments + + <%= command_path %> + daemon + run + + WorkingDirectory + <%= state_dir %> + EnvironmentVariables + +<% env.each do |k, v| -%> + <%= k %> + <%= v %> +<% end -%> + + RunAtLoad + + KeepAlive + + StandardOutPath + <%= log_path %> + StandardErrorPath + <%= log_path %> + ProcessType + Background + + diff --git a/templates/service/hive.service.erb b/templates/service/hive.service.erb new file mode 100644 index 00000000..7a35aa6f --- /dev/null +++ b/templates/service/hive.service.erb @@ -0,0 +1,19 @@ +[Unit] +Description=Hive daemon (per-user) +Documentation=https://github.com/ivankuznetsov/hive +After=default.target + +[Service] +Type=simple +ExecStart=<%= command_path %> daemon run +WorkingDirectory=<%= state_dir %> +Restart=on-failure +RestartSec=5 +<% env.each do |key, value| -%> +Environment="<%= systemd_escape("#{key}=#{value}") %>" +<% end -%> +StandardOutput=append:<%= log_path %> +StandardError=append:<%= log_path %> + +[Install] +WantedBy=default.target diff --git a/test/acceptance/install_lifecycle_test.rb b/test/acceptance/install_lifecycle_test.rb new file mode 100644 index 00000000..39fd42c6 --- /dev/null +++ b/test/acceptance/install_lifecycle_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "open3" +require "hive/project/scaffold" +require "hive/install_provenance" +require "hive/commands/uninstall" +require "hive/paths" +require "hive/platform" + +# Acceptance-style checks runnable on any host (AE1–AE5 contracted +# behaviors that do not require a clean macOS/AUR VM). +class InstallLifecycleAcceptanceTest < Minitest::Test + include HiveTestHelper + + def test_version_and_hv_alias + out_h = `#{RbConfig.ruby} -Ilib bin/hive --version`.strip + out_v = `#{RbConfig.ruby} -Ilib bin/hv --version`.strip + assert_equal out_h, out_v + assert_equal Hive::VERSION, out_h + end + + def test_platform_classification_present + tier = Hive::Platform.classify + assert_includes [ 1, 2, 3 ], tier.level + end + + def test_seeded_uninstall_preserves_work + with_tmp_dir do |dir| + old = %w[HOME HIVE_HOME XDG_STATE_HOME XDG_DATA_HOME XDG_CONFIG_HOME XDG_BIN_HOME].to_h do |key| + [ key, ENV[key] ] + end + ENV["HOME"] = dir + ENV["HIVE_HOME"] = File.join(dir, "hh") + ENV["XDG_STATE_HOME"] = File.join(dir, "st") + ENV["XDG_DATA_HOME"] = File.join(dir, "da") + ENV["XDG_CONFIG_HOME"] = File.join(dir, "cf") + ENV["XDG_BIN_HOME"] = File.join(dir, "bin") + FileUtils.mkdir_p(ENV["HIVE_HOME"]) + + state = File.join(Hive::Paths.state_home, "done.txt") + FileUtils.mkdir_p(File.dirname(state)) + File.write(state, "keep\n") + + proj = File.join(dir, "p") + FileUtils.mkdir_p(proj) + Hive::Project::Scaffold.new(proj).call + File.write(File.join(proj, "app.rb"), "1\n") + + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + + Hive::Commands::Uninstall.new( + project_path: proj, yes: true, clean_project: false, + command_path: command, service_adapter: nil, + output: StringIO.new, input: StringIO.new + ).call + + assert_equal "keep\n", File.read(state) + assert_equal "1\n", File.read(File.join(proj, "app.rb")) + ensure + old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end + + def test_prompt_and_docs_exist + root = File.expand_path("../..", __dir__) + %w[ + INSTALL_PROMPT.md + install.sh + docs/install.md + docs/configuration.md + docs/daemon.md + docs/update-uninstall.md + docs/migration.md + docs/troubleshooting.md + ].each do |rel| + assert File.exist?(File.join(root, rel)), "missing #{rel}" + end + end + + def test_skills_package_validates + root = File.expand_path("../../packaging/hive-skills", __dir__) + _o, err, st = Open3.capture3("ruby", File.join(root, "test/validate_manifests.rb")) + assert st.success?, err + end +end diff --git a/test/fixtures/install_hosts/arch_yay.json b/test/fixtures/install_hosts/arch_yay.json new file mode 100644 index 00000000..b056f338 --- /dev/null +++ b/test/fixtures/install_hosts/arch_yay.json @@ -0,0 +1,9 @@ +{ + "os": "linux", + "distro": "arch", + "arch": "x86_64", + "homebrew": false, + "aur_helper": "yay", + "unrelated_hive": false, + "expected_channel": "aur" +} diff --git a/test/fixtures/install_hosts/collision_hv.json b/test/fixtures/install_hosts/collision_hv.json new file mode 100644 index 00000000..cf48932a --- /dev/null +++ b/test/fixtures/install_hosts/collision_hv.json @@ -0,0 +1,11 @@ +{ + "os": "linux", + "distro": "ubuntu", + "arch": "x86_64", + "homebrew": false, + "aur_helper": null, + "unrelated_hive": true, + "unrelated_hive_identity": "Apache Hive", + "expected_channel": "bash_hv", + "command_name": "hv" +} diff --git a/test/fixtures/install_hosts/macos_arm64_brew.json b/test/fixtures/install_hosts/macos_arm64_brew.json new file mode 100644 index 00000000..0d15f49a --- /dev/null +++ b/test/fixtures/install_hosts/macos_arm64_brew.json @@ -0,0 +1,8 @@ +{ + "os": "macos", + "arch": "arm64", + "homebrew": true, + "aur_helper": null, + "unrelated_hive": false, + "expected_channel": "homebrew" +} diff --git a/test/fixtures/install_hosts/ubuntu_bash.json b/test/fixtures/install_hosts/ubuntu_bash.json new file mode 100644 index 00000000..e745678b --- /dev/null +++ b/test/fixtures/install_hosts/ubuntu_bash.json @@ -0,0 +1,9 @@ +{ + "os": "linux", + "distro": "ubuntu", + "arch": "x86_64", + "homebrew": false, + "aur_helper": null, + "unrelated_hive": false, + "expected_channel": "bash" +} diff --git a/test/integration/daemon_command_test.rb b/test/integration/daemon_command_test.rb index ea385c4d..b136aa57 100644 --- a/test/integration/daemon_command_test.rb +++ b/test/integration/daemon_command_test.rb @@ -14,7 +14,14 @@ class HiveDaemonCommandTest < Minitest::Test def with_isolated_hive_home(&block) Dir.mktmpdir("hive-daemon-test") do |home| - env = ENV.to_h.merge("HIVE_HOME" => home) + # Isolate both process state and service-definition discovery. Without + # XDG_CONFIG_HOME, a developer's real registered user unit makes these + # direct-PID compatibility fixtures route through the platform adapter. + env = ENV.to_h.merge( + "HOME" => home, + "HIVE_HOME" => home, + "XDG_CONFIG_HOME" => File.join(home, "config") + ) block.call(home, env) end end @@ -38,6 +45,7 @@ class HiveDaemonCommandTest < Minitest::Test assert_equal "hive-daemon-status", doc["schema"] assert_equal false, doc["running"] assert_equal File.join(home, ".daemon.pid"), doc["pid_file"] + assert doc.key?("service") assert_equal 1, status.exitstatus end end @@ -1164,7 +1172,7 @@ class HiveDaemonCommandTest < Minitest::Test assert_equal Hive::ExitCodes::USAGE, status.exitstatus, "bare `hive daemon` must exit 64 (USAGE) with the same shape as `hive daemon frobnicate`" assert_match(/missing SUBCOMMAND/, err) - assert_match(/start, stop, status, reload, tail, enable, disable/, err) + assert_match(/start, run, stop, status, reload, tail, enable, disable/, err) end end diff --git a/test/integration/daemon_lifecycle_test.rb b/test/integration/daemon_lifecycle_test.rb new file mode 100644 index 00000000..d8c2b7d8 --- /dev/null +++ b/test/integration/daemon_lifecycle_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/service/adapter" +require "hive/service/systemd_user" + +class DaemonLifecycleTest < Minitest::Test + include HiveTestHelper + + def test_default_contract_uses_stable_command_path + with_tmp_dir do |dir| + old = ENV["HIVE_COMMAND_PATH"] + ENV["HIVE_COMMAND_PATH"] = File.join(dir, "hive") + begin + c = Hive::Service::Adapter.default_contract + assert_equal File.join(dir, "hive"), c.command_path + refute_empty c.state_dir + refute_empty c.log_path + ensure + old.nil? ? ENV.delete("HIVE_COMMAND_PATH") : ENV["HIVE_COMMAND_PATH"] = old + end + end + end + + def test_refresh_preserves_running_intent + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: File.join(dir, "hive"), + state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + started = false + enabled = false + adapter.define_singleton_method(:register!) { true } + adapter.define_singleton_method(:start!) { started = true } + adapter.define_singleton_method(:enable!) { enabled = true } + + adapter.refresh!(was_enabled: true, was_running: false) + refute started + assert enabled + + started = false + enabled = false + adapter.refresh!(was_enabled: false, was_running: true) + assert started + refute enabled + end + end + + + def test_command_resolution_rejects_unrelated_hive_and_uses_verified_hv + with_tmp_dir do |dir| + hive = File.join(dir, "hive") + hv = File.join(dir, "hv") + File.write(hive, "#!/bin/sh\necho 'Apache Hive 4.0'\n") + File.write(hv, "#!/bin/sh\necho '#{Hive::VERSION}'\n") + FileUtils.chmod(0o755, [ hive, hv ]) + old_path = ENV["PATH"] + old_command = ENV.delete("HIVE_COMMAND_PATH") + ENV["PATH"] = "#{dir}:#{old_path}" + begin + assert_equal hv, Hive::Service::Adapter.resolve_command_path + ensure + ENV["PATH"] = old_path + old_command.nil? ? ENV.delete("HIVE_COMMAND_PATH") : ENV["HIVE_COMMAND_PATH"] = old_command + end + end + end +end diff --git a/test/integration/fixtures/tui-fake-child b/test/integration/fixtures/tui-fake-child index 3a9fdbd6..2836a069 100755 --- a/test/integration/fixtures/tui-fake-child +++ b/test/integration/fixtures/tui-fake-child @@ -7,6 +7,7 @@ # 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_BLOCK_SECONDS when blocking, exit after this many seconds if [[ "$HIVE_TUI_FAKE_TRAP_INT" == "1" ]]; then trap 'echo "fake-child:caught-SIGINT"; exit 130' INT @@ -18,6 +19,10 @@ if [[ -n "$HIVE_TUI_FAKE_STDERR" ]]; then printf '%s' "$HIVE_TUI_FAKE_STDERR" 1>&2 fi if [[ "$HIVE_TUI_FAKE_BLOCK" == "1" ]]; then + if [[ -n "$HIVE_TUI_FAKE_BLOCK_SECONDS" ]]; then + sleep "$HIVE_TUI_FAKE_BLOCK_SECONDS" + exit "${HIVE_TUI_FAKE_EXIT:-0}" + fi while true; do sleep 0.1; done fi exit "${HIVE_TUI_FAKE_EXIT:-0}" diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb index 0b423861..f10a543c 100644 --- a/test/integration/init_test.rb +++ b/test/integration/init_test.rb @@ -113,14 +113,16 @@ 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" + FileUtils.rm_f(File.join(dir, ".hive", "config.example.yml")) + out, err, status = with_captured_exit { Hive::Commands::Init.new(dir).call } + assert_equal 0, status + assert_match(/service registered|doctor pre-flight/, err) + assert_includes out, "hive: initialized" + assert File.exist?(File.join(dir, ".hive", "config.example.yml")) end end end @@ -287,9 +289,9 @@ class InitTest < Minitest::Test end end - def test_init_already_initialized_short_circuits_before_any_prompt - # On a re-run of `hive init` the AlreadyInitialized guard must fire - # BEFORE the prompt module reads anything from stdin. We feed an + def test_init_already_initialized_repairs_without_reprompting + # On a re-run of `hive init` an existing project config means the + # project prompt is not consumed. We feed an # input stream that would crash the prompt validator if consumed # ('crash'), then assert it's still pristine after the second init. with_tmp_global_config do @@ -304,8 +306,8 @@ class InitTest < Minitest::Test _, err, status = with_captured_exit do Hive::Commands::Init.new(dir, prompts: prompts).call end - assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status - assert_includes err, "already initialized" + assert_equal 0, status + assert_match(/service registered|doctor pre-flight/, err) 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/lifecycle_safety_test.rb b/test/integration/lifecycle_safety_test.rb new file mode 100644 index 00000000..d1206b44 --- /dev/null +++ b/test/integration/lifecycle_safety_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/project/scaffold" +require "hive/commands/uninstall" +require "hive/install_provenance" +require "hive/paths" + +class LifecycleSafetyTest < Minitest::Test + include HiveTestHelper + + def test_uninstall_preserves_seeded_work + with_tmp_dir do |dir| + old = { + "HOME" => ENV["HOME"], + "HIVE_HOME" => ENV["HIVE_HOME"], + "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], + "XDG_STATE_HOME" => ENV["XDG_STATE_HOME"], + "XDG_CONFIG_HOME" => ENV["XDG_CONFIG_HOME"] + } + ENV["HOME"] = dir + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["XDG_STATE_HOME"] = File.join(dir, "state") + ENV["XDG_CONFIG_HOME"] = File.join(dir, "config") + FileUtils.mkdir_p(ENV["HIVE_HOME"]) + + # Seed user work that must survive. + state_file = File.join(Hive::Paths.state_home, "completed-output.md") + FileUtils.mkdir_p(File.dirname(state_file)) + File.write(state_file, "done work\n") + cfg = File.join(Hive::Paths.config_home, "config.yml") + FileUtils.mkdir_p(File.dirname(cfg)) + File.write(cfg, "provider: env\n") + + project = File.join(dir, "proj") + FileUtils.mkdir_p(project) + Hive::Project::Scaffold.new(project).call + File.write(File.join(project, "user-code.rb"), "puts 1\n") + + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + + out = StringIO.new + Hive::Commands::Uninstall.new( + project_path: project, + yes: true, + clean_project: false, + command_path: command, + service_adapter: nil, + output: out, + input: StringIO.new + ).call + + assert_equal "done work\n", File.read(state_file) + assert_equal "provider: env\n", File.read(cfg) + assert_equal "puts 1\n", File.read(File.join(project, "user-code.rb")) + assert File.exist?(File.join(project, ".hive", "README.md")) + refute File.exist?(File.join(Hive::Paths.bin_dir, "hv")) + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end +end diff --git a/test/integration/runtime_closure_test.rb b/test/integration/runtime_closure_test.rb new file mode 100644 index 00000000..582bb893 --- /dev/null +++ b/test/integration/runtime_closure_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "test_helper" +require "rbconfig" +require "hive/assets" +require "hive/paths" + +class RuntimeClosureTest < Minitest::Test + include HiveTestHelper + + def test_version_identical_as_hive_and_hv + hive_out = run!(RbConfig.ruby, "-Ilib", "bin/hive", "--version") + hv_out = run!(RbConfig.ruby, "-Ilib", "bin/hv", "--version") + assert_equal hive_out, hv_out + assert_equal "#{Hive::VERSION}\n", hive_out + end + + def test_assets_materialize_under_xdg_data + with_tmp_dir do |home| + old = { + "HOME" => ENV["HOME"], + "HIVE_HOME" => ENV["HIVE_HOME"], + "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], + "HIVE_PACKAGE_ROOT" => ENV["HIVE_PACKAGE_ROOT"] + } + ENV["HOME"] = home + ENV.delete("HIVE_HOME") + ENV["XDG_DATA_HOME"] = File.join(home, "share") + # __dir__ is test/integration → repo root is two levels up + ENV["HIVE_PACKAGE_ROOT"] = File.expand_path("../..", __dir__) + + begin + dest = Hive::Assets.materialize! + assert File.directory?(dest) + assert_equal File.join(home, "share", "hive", "versions", Hive::VERSION), dest + assert File.exist?(File.join(dest, ".materialized")) + # Re-run is idempotent. + assert_equal dest, Hive::Assets.materialize! + + template = File.join(dest, "templates", "project", "config.yml") + original = File.read(template) + File.write(template, "corrupted\n") + refute Hive::Assets.verified?(dest) + Hive::Assets.materialize! + assert_equal original, File.read(template) + + FileUtils.rm_f(template) + refute Hive::Assets.verified?(dest) + Hive::Assets.materialize! + assert_equal original, File.read(template) + ensure + old.each do |k, v| + if v.nil? + ENV.delete(k) + else + ENV[k] = v + end + end + end + end + end + + def test_help_works_as_hv + out = run!(RbConfig.ruby, "-Ilib", "bin/hv", "help", "version") + assert_match(/version/i, out) + end +end diff --git a/test/integration/tui_subprocess_test.rb b/test/integration/tui_subprocess_test.rb index 34ad39c3..0d751ed2 100644 --- a/test/integration/tui_subprocess_test.rb +++ b/test/integration/tui_subprocess_test.rb @@ -24,12 +24,14 @@ 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_BLOCK_SECONDS].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_BLOCK_SECONDS].each { |k| ENV.delete(k) } Hive::Tui::SubprocessRegistry.clear end @@ -315,26 +317,17 @@ 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 + ENV["HIVE_TUI_FAKE_BLOCK_SECONDS"] = "0.5" 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. - killer = Thread.new do - sleep 0.3 - `pgrep -f tui-fake-child`.split.each do |pid| - Process.kill("TERM", pid.to_i) - pids_mutex.synchronize { pids_killed << pid.to_i } - rescue Errno::ESRCH - nil - end - end - + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) assert wait_for_messages(2, timeout: 5.0), "both reapers must dispatch SubprocessExited; concurrent runs are the whole point" - killer.join + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + assert_operator elapsed, :<, 0.9, + "two 0.5s children should overlap instead of taking ~1.0s" verbs = @messages.map(&:verb).sort assert_equal %w[develop review], verbs, "each dispatch must surface its own verb on completion (no cross-talk)" diff --git a/test/test_helper.rb b/test/test_helper.rb index d251d52b..487af9c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -68,15 +68,42 @@ module HiveTestHelper out end + + def install_fake_bash_channel(prefix:, version: Hive::VERSION, command_names: [ "hive" ]) + binary = File.join(prefix, "versions", version, "hive") + FileUtils.mkdir_p(File.dirname(binary)) + File.write(binary, "#!/bin/sh\nprintf '%s\\n' '#{version}'\n") + FileUtils.chmod(0o755, binary) + FileUtils.mkdir_p(Hive::Paths.bin_dir) + command_names.each do |name| + FileUtils.ln_sf(binary, File.join(Hive::Paths.bin_dir, name)) + end + Hive::InstallProvenance.write_bash_receipt!( + version: version, + command_name: command_names.first, + command_names: command_names, + prefix: prefix, + binary_path: binary, + bin_dir: Hive::Paths.bin_dir + ) + File.join(Hive::Paths.bin_dir, command_names.first) + end + def with_tmp_global_config dir = Dir.mktmpdir("hive-global") - old = ENV["HIVE_HOME"] + old = %w[HIVE_HOME HIVE_PROVIDER HIVE_MODEL HIVE_API_KEY_ENV XDG_CONFIG_HOME].to_h do |key| + [ key, ENV[key] ] + end ENV["HIVE_HOME"] = dir + ENV["XDG_CONFIG_HOME"] = File.join(dir, "xdg-config") + ENV["HIVE_PROVIDER"] = "openai" + ENV["HIVE_MODEL"] = "gpt-5" + ENV["HIVE_API_KEY_ENV"] = "OPENAI_API_KEY" File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml) begin yield(dir) ensure - ENV["HIVE_HOME"] = old + old.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } # 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/commands/daemon_envelope_test.rb b/test/unit/commands/daemon_envelope_test.rb index 4e270298..da7587a2 100644 --- a/test/unit/commands/daemon_envelope_test.rb +++ b/test/unit/commands/daemon_envelope_test.rb @@ -13,6 +13,60 @@ require "hive/commands/daemon" class HiveCommandsDaemonEnvelopeTest < Minitest::Test include HiveTestHelper + class FakeServiceAdapter + attr_reader :starts, :stops + + def initialize(registered: true, running: false) + @registered = registered + @running = running + @starts = 0 + @stops = 0 + end + + def status + { + "manager" => "fake-user-service", + "registered" => @registered, + "enabled" => @registered, + "running" => @running + } + end + + def start! + @starts += 1 + @running = true + end + + def stop! + @stops += 1 + @running = false + true + end + end + + def test_public_start_and_stop_route_through_registered_service_adapter + adapter = FakeServiceAdapter.new + out, = capture_io do + Hive::Commands::Daemon.new("start", service_adapter: adapter).call + Hive::Commands::Daemon.new("stop", service_adapter: adapter).call + end + assert_equal 1, adapter.starts + assert_equal 1, adapter.stops + assert_match(/started via fake-user-service/, out) + assert_match(/stopped via fake-user-service/, out) + end + + def test_status_uses_registered_service_state_and_always_emits_service + adapter = FakeServiceAdapter.new(running: true) + out, = capture_io do + Hive::Commands::Daemon.new("status", json: true, service_adapter: adapter).call + end + payload = JSON.parse(out) + assert_equal true, payload["running"] + assert_equal "fake-user-service", payload.dig("service", "manager") + assert payload.key?("service") + end + # Verifies the StandardError → InternalError → envelope chain the # EnvelopeEmitter mixin promises in lib/hive.rb. Without this test, # a refactor of call_with_envelope could silently lose the envelope diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb new file mode 100644 index 00000000..5e0ff88d --- /dev/null +++ b/test/unit/commands/uninstall_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/uninstall" +require "hive/project/scaffold" +require "hive/install_provenance" + +class CommandsUninstallTest < Minitest::Test + include HiveTestHelper + + def test_preview_preserves_state + with_tmp_dir do |dir| + old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] } + ENV["HOME"] = dir + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + FileUtils.mkdir_p(ENV["HIVE_HOME"]) + state = File.join(dir, "share", "hive") + FileUtils.mkdir_p(state) + File.write(File.join(state, "work.bin"), "important\n") + + Hive::Project::Scaffold.new(dir).call + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + + out = StringIO.new + Hive::Commands::Uninstall.new( + project_path: dir, + dry_run: true, + command_path: command, + service_adapter: nil, + output: out, + input: StringIO.new + ).call + + assert File.exist?(File.join(state, "work.bin")), "XDG state must survive uninstall preview" + assert_match(/preview/, out.string) + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end + + def test_clean_project_removes_only_unmodified + with_tmp_dir do |dir| + old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] } + ENV["HOME"] = dir + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + FileUtils.mkdir_p(ENV["HIVE_HOME"]) + + Hive::Project::Scaffold.new(dir).call + File.write(File.join(dir, ".hive", "README.md"), "user changed\n") + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + + out = StringIO.new + Hive::Commands::Uninstall.new( + project_path: dir, + yes: true, + clean_project: true, + command_path: command, + service_adapter: nil, + output: out, + input: StringIO.new, + exec: ->(*) { true } + ).call + + assert File.exist?(File.join(dir, ".hive", "README.md")), "modified scaffold must be preserved" + assert_match(/preserved/, out.string) + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end + + def test_cancel_at_preview + with_tmp_dir do |dir| + old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] } + ENV["HOME"] = dir + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + FileUtils.mkdir_p(ENV["HIVE_HOME"]) + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + + input = StringIO.new("n\n") + def input.tty? = true + out = StringIO.new + Hive::Commands::Uninstall.new( + project_path: dir, + yes: false, + command_path: command, + service_adapter: nil, + output: out, + input: input + ).call + assert_match(/no changes made/, out.string) + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end + + def test_unknown_ownership_aborts_before_service_or_project_mutation + with_tmp_dir do |dir| + old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] } + ENV["HOME"] = dir + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + Hive::Project::Scaffold.new(dir).call + calls = [] + adapter = Object.new + adapter.define_singleton_method(:stop!) { calls << :stop; true } + adapter.define_singleton_method(:unregister!) { calls << :unregister; true } + + error = assert_raises(Hive::Error) do + Hive::Commands::Uninstall.new( + project_path: dir, + yes: true, + clean_project: true, + command_path: File.join(dir, "unrelated"), + service_adapter: adapter, + output: StringIO.new + ).call + end + assert_match(/No service, project, or binary changes/, error.message) + assert_empty calls + assert File.exist?(File.join(dir, ".hive", "README.md")) + ensure + old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end + + def test_service_cleanup_failure_stops_before_binary_removal + with_tmp_dir do |dir| + old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] } + ENV["HOME"] = dir + ENV["XDG_DATA_HOME"] = File.join(dir, "share") + ENV["HIVE_HOME"] = File.join(dir, "hive-home") + command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ]) + adapter = Object.new + adapter.define_singleton_method(:stop!) { false } + adapter.define_singleton_method(:unregister!) { raise "must not run" } + + error = assert_raises(Hive::Error) do + Hive::Commands::Uninstall.new( + project_path: dir, + yes: true, + command_path: command, + service_adapter: adapter, + output: StringIO.new + ).call + end + assert_match(/failed to stop/, error.message) + assert File.exist?(command) + assert File.exist?(Hive::InstallProvenance.receipt_path) + ensure + old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end +end diff --git a/test/unit/commands/update_test.rb b/test/unit/commands/update_test.rb new file mode 100644 index 00000000..194d1833 --- /dev/null +++ b/test/unit/commands/update_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/commands/update" +require "hive/install_provenance" + +class CommandsUpdateTest < Minitest::Test + include HiveTestHelper + + def with_update_home + with_tmp_dir do |home| + keys = %w[HOME HIVE_HOME XDG_DATA_HOME XDG_BIN_HOME] + old = keys.to_h { |key| [ key, ENV[key] ] } + ENV["HOME"] = home + ENV.delete("HIVE_HOME") + ENV["XDG_DATA_HOME"] = File.join(home, "share") + ENV["XDG_BIN_HOME"] = File.join(home, "bin") + yield home + ensure + old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end + + def test_unknown_ownership_raises + with_update_home do + err = assert_raises(Hive::Error) do + Hive::Commands::Update.new(command_path: "/tmp/nope", dry_run: true).call + end + assert_match(/ownership/, err.message) + end + end + + def test_bash_channel_dry_run + with_update_home do |home| + command = install_fake_bash_channel(prefix: home) + executed = [] + owner = Hive::Commands::Update.new( + command_path: command, + dry_run: true, + exec: ->(*args) { executed << args; true } + ).call + assert_equal :bash, owner.channel + assert_empty executed + end + end + + def test_package_manager_failure_does_not_continue + with_update_home do |home| + command = install_fake_bash_channel(prefix: home) + err = assert_raises(Hive::Error) do + Hive::Commands::Update.new( + command_path: command, + dry_run: false, + exec: ->(*) { false } + ).call + end + assert_match(/package manager failed/, err.message) + end + end + + + def test_success_verifies_resulting_version_and_service_state + with_update_home do |home| + command = install_fake_bash_channel(prefix: home) + adapter = Struct.new(:state) do + def status = state.dup + def refresh!(was_enabled:, was_running:) + self.state = { "enabled" => was_enabled, "running" => was_running } + true + end + end.new({ "registered" => true, "enabled" => true, "running" => true }) + out, = capture_io do + Hive::Commands::Update.new( + command_path: command, + exec: ->(*) { true }, + service_adapter: adapter, + version_probe: ->(_path) { "0.2.0" } + ).call + end + assert_match(/version=0\.2\.0/, out) + end + end + + def test_service_refresh_failure_is_fatal + with_update_home do |home| + command = install_fake_bash_channel(prefix: home) + adapter = Struct.new(:state) do + def status = state + def refresh!(**) = raise("refresh broke") + end.new({ "registered" => true, "enabled" => false, "running" => true }) + error = assert_raises(Hive::Error) do + Hive::Commands::Update.new( + command_path: command, + exec: ->(*) { true }, + service_adapter: adapter, + version_probe: ->(*) { "0.2.0" } + ).call + end + assert_match(/service refresh failed/, error.message) + end + end + + def test_update_does_not_register_a_service_that_was_absent + with_update_home do |home| + command = install_fake_bash_channel(prefix: home) + refreshed = false + adapter = Object.new + adapter.define_singleton_method(:status) do + { "registered" => false, "enabled" => false, "running" => false } + end + adapter.define_singleton_method(:refresh!) { |**| refreshed = true } + + Hive::Commands::Update.new( + command_path: command, + exec: ->(*) { true }, + service_adapter: adapter, + version_probe: ->(*) { "0.2.0" } + ).call + + refute refreshed + end + end +end diff --git a/test/unit/dependency_check_test.rb b/test/unit/dependency_check_test.rb new file mode 100644 index 00000000..e219dc82 --- /dev/null +++ b/test/unit/dependency_check_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/dependency_check" +require "fileutils" + +class DependencyCheckTest < Minitest::Test + include HiveTestHelper + + def test_git_present_on_ci_host + results = Hive::DependencyCheck.check_all + git = results.find { |c| c.name == "git" } + assert git.present, "git should be present on the CI host" + assert_equal :hard, git.kind + end + + def test_missing_tool_reported_with_hint + with_tmp_dir do |dir| + # Empty PATH → everything missing. + results = Hive::DependencyCheck.check_all(path_env: dir) + hard = results.select { |c| c.kind == :hard } + assert hard.all? { |c| !c.present } + hard.each { |c| refute_empty c.hint } + + io = StringIO.new + Hive::DependencyCheck.report(io: io, path_env: dir) + assert_match(/dependency diagnostics/, io.string) + assert_match(/missing git/, io.string) + end + end + + def test_optional_missing_does_not_appear_as_hard + with_tmp_dir do |dir| + # Provide only git and bash. + %w[git bash].each do |name| + path = File.join(dir, name) + File.write(path, "#!/bin/sh\n") + File.chmod(0o755, path) + end + + results = Hive::DependencyCheck.check_all(path_env: dir) + assert results.find { |c| c.name == "git" }.present + assert results.find { |c| c.name == "bash" }.present + + gh = results.find { |c| c.name == "gh" } + refute gh.present + assert_equal :optional, gh.kind + assert_equal "github_integration", gh.capability + end + end + + def test_hard_missing_helper + with_tmp_dir do |dir| + missing = Hive::DependencyCheck.hard_missing(path_env: dir) + assert missing.any? { |c| c.name == "git" } + end + end +end diff --git a/test/unit/install/bash_installer_test.rb b/test/unit/install/bash_installer_test.rb new file mode 100644 index 00000000..5e8b42c2 --- /dev/null +++ b/test/unit/install/bash_installer_test.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "json" +require "digest" + +class BashInstallerTest < Minitest::Test + include HiveTestHelper + + def install_sh + File.expand_path("../../../install.sh", __dir__) + end + + def test_bash_syntax + _o, err, st = Open3.capture3("bash", "-n", install_sh) + assert st.success?, err + end + + def test_dry_run_no_mutation + with_tmp_dir do |dir| + env = { + "HOME" => dir, + "PATH" => ENV["PATH"], + "XDG_DATA_HOME" => File.join(dir, "share"), + "XDG_BIN_HOME" => File.join(dir, "bin") + } + out, err, st = Open3.capture3(env, "bash", install_sh, "--dry-run", "--version", "0.1.0") + assert st.success?, err + assert_match(/dry-run/, out) + refute File.exist?(File.join(dir, "share", "hive")) + end + end + + def test_rejects_bad_command_name + _o, err, st = Open3.capture3("bash", install_sh, "--command-name", "evil", "--dry-run") + refute st.success? + assert_match(/command-name/, err) + end + + def test_rejects_traversal_version_before_deriving_install_paths + with_tmp_dir do |dir| + sentinel = File.join(dir, "sentinel") + File.write(sentinel, "keep\n") + env = isolated_env(dir) + _out, err, status = Open3.capture3( + env, "bash", install_sh, "--version", "../../sentinel", "--dry-run" + ) + refute status.success? + assert_match(/MAJOR\.MINOR\.PATCH/, err) + assert_equal "keep\n", File.read(sentinel) + end + end + + def test_installs_both_aliases_and_canonical_custom_prefix_receipt + with_tmp_dir do |dir| + release = build_release_fixture(dir) + prefix = File.join(dir, "custom-payload") + env = isolated_env(dir) + out, err, status = Open3.capture3( + env, "bash", install_sh, + "--version", "0.1.0", "--prefix", prefix, + "--base-url", "file://#{release}", "--skip-provenance" + ) + assert status.success?, "#{err}\n#{out}" + %w[hive hv].each do |name| + command = File.join(dir, "bin", name) + assert File.symlink?(command) + assert_equal "0.1.0", Open3.capture2(command, "--version").first.strip + end + receipt = YAML.safe_load(File.read(File.join(dir, "share", "hive", "install-receipt.yml"))) + assert_equal prefix, receipt["prefix"] + assert_equal %w[hive hv], receipt["command_names"] + refute File.exist?(File.join(prefix, "install-receipt.yml")) + end + end + + def test_rejects_archive_links_before_extraction + with_tmp_dir do |dir| + release = build_release_fixture(dir, escaping_symlink: true) + env = isolated_env(dir) + _out, err, status = Open3.capture3( + env, "bash", install_sh, + "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance" + ) + refute status.success? + assert_match(/link or special file/, err) + refute File.exist?(File.join(dir, "outside")) + end + end + + def test_same_version_keeps_previous_payload_when_post_install_verification_fails + with_tmp_dir do |dir| + release = build_release_fixture(dir) + env = isolated_env(dir) + first = Open3.capture3( + env, "bash", install_sh, + "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance" + ) + assert first[2].success?, first[1] + + failing_body = <<~SH + #!/bin/sh + case "$0" in + *.staging.*) echo 0.1.0 ;; + *) exit 9 ;; + esac + SH + build_release_fixture(dir, script_body: failing_body, replace: true) + _out, err, status = Open3.capture3( + env, "bash", install_sh, + "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance" + ) + refute status.success? + assert_match(/post-install verification failed/, err) + command = File.join(dir, "bin", "hive") + assert_equal "0.1.0", Open3.capture2(command, "--version").first.strip + end + end + + def test_failed_collision_safe_install_never_removes_unrelated_hive + with_tmp_dir do |dir| + unrelated = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(unrelated)) + File.write(unrelated, "#!/bin/sh\necho Apache Hive\n") + FileUtils.chmod(0o755, unrelated) + failing_body = <<~SH + #!/bin/sh + case "$0" in + *.staging.*) echo 0.1.0 ;; + *) exit 9 ;; + esac + SH + release = build_release_fixture(dir, script_body: failing_body) + env = isolated_env(dir) + + _out, err, status = Open3.capture3( + env, "bash", install_sh, + "--version", "0.1.0", "--command-name", "hv", + "--base-url", "file://#{release}", "--skip-provenance" + ) + + refute status.success? + assert_match(/post-install verification failed/, err) + assert_equal "#!/bin/sh\necho Apache Hive\n", File.read(unrelated) + refute File.exist?(File.join(dir, "bin", "hv")) + end + end + + private + + def isolated_env(dir) + { + "HOME" => dir, + "PATH" => "/usr/bin:/bin", + "XDG_DATA_HOME" => File.join(dir, "share"), + "XDG_BIN_HOME" => File.join(dir, "bin") + } + end + + def build_release_fixture(dir, script_body: "#!/bin/sh\necho 0.1.0\n", escaping_symlink: false, replace: false) + release = File.join(dir, "release") + FileUtils.rm_rf(release) if replace + FileUtils.mkdir_p(release) + stage = File.join(dir, "stage") + FileUtils.rm_rf(stage) + FileUtils.mkdir_p(File.join(stage, "hive")) + binary = File.join(stage, "hive", "hive") + File.write(binary, script_body) + FileUtils.chmod(0o755, binary) + File.symlink("../../outside", File.join(stage, "hive", "escape")) if escaping_symlink + + os = RbConfig::CONFIG["host_os"].include?("darwin") ? "macos" : "linux" + cpu = RbConfig::CONFIG["host_cpu"] + arch = %w[arm64 aarch64].include?(cpu) ? "arm64" : "x86_64" + archive_name = "hive-#{os}-#{arch}.tar.gz" + archive = File.join(release, archive_name) + _out, err, status = Open3.capture3("tar", "-C", stage, "-czf", archive, "hive") + raise err unless status.success? + + digest = Digest::SHA256.file(archive).hexdigest + File.write(File.join(release, "SHA256SUMS"), "#{digest} #{archive_name}\n") + File.write(File.join(release, "manifest.json"), JSON.generate( + "assets" => [ { + "filename" => archive_name, + "sha256" => digest, + "url" => "file://#{archive}" + } ] + )) + release + end +end diff --git a/test/unit/install/prompt_contract_test.rb b/test/unit/install/prompt_contract_test.rb new file mode 100644 index 00000000..0c704c01 --- /dev/null +++ b/test/unit/install/prompt_contract_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" + +class PromptContractTest < Minitest::Test + def prompt + File.read(File.expand_path("../../../INSTALL_PROMPT.md", __dir__)) + end + + def test_mentions_collision_hv + assert_match(/--command-name hv/, prompt) + assert_match(/Apache Hive/, prompt) + end + + def test_mentions_all_channels + assert_match(/brew install/, prompt) + assert_match(/hive-bin/, prompt) + assert_match(/install\.sh/, prompt) + end + + def test_skills_native_only + assert_match(/claude plugin/, prompt) + assert_match(/codex plugin/, prompt) + assert_match(/pi install/, prompt) + assert_match(/without copying files into dot-directories/, prompt) + end + + def test_no_autostart + assert_match(/Never enable or start the daemon without asking/, prompt) + end +end diff --git a/test/unit/install/selector_test.rb b/test/unit/install/selector_test.rb new file mode 100644 index 00000000..38b6f4e0 --- /dev/null +++ b/test/unit/install/selector_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" + +class InstallSelectorTest < Minitest::Test + FIXTURES = File.expand_path("../../fixtures/install_hosts", __dir__) + + def select_channel(doc) + return "bash_hv" if doc["unrelated_hive"] + return "homebrew" if doc["os"] == "macos" && doc["homebrew"] + return "aur" if doc["distro"] == "arch" && doc["aur_helper"] + + "bash" + end + + def test_fixtures_match_selector + Dir[File.join(FIXTURES, "*.json")].each do |path| + doc = JSON.parse(File.read(path)) + assert_equal doc["expected_channel"], select_channel(doc), path + end + end +end diff --git a/test/unit/install_provenance_test.rb b/test/unit/install_provenance_test.rb new file mode 100644 index 00000000..45515109 --- /dev/null +++ b/test/unit/install_provenance_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/install_provenance" +require "hive/paths" + +class InstallProvenanceTest < Minitest::Test + include HiveTestHelper + + def with_data_home + with_tmp_dir do |home| + old = { "HOME" => ENV["HOME"], "HIVE_HOME" => ENV["HIVE_HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"] } + ENV["HOME"] = home + ENV.delete("HIVE_HOME") + ENV["XDG_DATA_HOME"] = File.join(home, "share") + begin + yield home + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end + end + + def test_bash_receipt_selects_bash_channel + with_data_home do + command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hv" ]) + owner = Hive::InstallProvenance.detect(command_path: command) + assert_equal :bash, owner.channel + assert_equal "hv", owner.details["command_name"] + assert owner.updater + end + end + + + def test_stale_receipt_does_not_claim_a_replaced_command + with_data_home do + command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hv" ]) + FileUtils.rm_f(command) + File.write(command, "#!/bin/sh\necho unrelated\n") + FileUtils.chmod(0o755, command) + + owner = Hive::InstallProvenance.detect(command_path: command) + assert_equal :unknown, owner.channel + end + end + + def test_bash_updater_is_a_pinned_verifier_not_curl_pipe_bash + with_data_home do + command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hive", "hv" ]) + owner = Hive::InstallProvenance.detect(command_path: command) + assert_equal :bash, owner.channel + assert_match(/bash_update\.sh\z/, owner.updater.first) + refute owner.updater.join(" ").include?("latest/download/install.sh") + assert File.executable?(owner.updater.first) + end + end + + + def test_custom_prefix_receipt_remains_discoverable_from_canonical_xdg_path + with_data_home do |home| + prefix = File.join(home, "custom-prefix") + command = install_fake_bash_channel(prefix: prefix, command_names: [ "hv" ]) + assert_equal File.join(home, "share", "hive", "install-receipt.yml"), + Hive::InstallProvenance.receipt_path + owner = Hive::InstallProvenance.detect(command_path: command) + assert_equal :bash, owner.channel + assert_equal prefix, owner.details["prefix"] + end + end + + def test_stable_homebrew_symlink_uses_fully_qualified_tap_formula + with_data_home do |home| + tools = File.join(home, "tools") + prefix = File.join(home, "Cellar", "hive", "0.1.0") + FileUtils.mkdir_p([ tools, File.join(prefix, "bin"), File.join(home, "bin") ]) + binary = File.join(prefix, "bin", "hive") + File.write(binary, "#!/bin/sh\necho 0.1.0\n") + FileUtils.chmod(0o755, binary) + stable = File.join(home, "bin", "hive") + FileUtils.ln_sf(binary, stable) + brew = File.join(tools, "brew") + File.write(brew, <<~SH) + #!/bin/sh + if [ "$1" = "--prefix" ] && [ "$2" = "ivankuznetsov/hive/hive" ]; then + echo "#{prefix}" + exit 0 + fi + exit 1 + SH + FileUtils.chmod(0o755, brew) + old_path = ENV["PATH"] + ENV["PATH"] = "#{tools}:#{old_path}" + begin + owner = Hive::InstallProvenance.detect(command_path: stable) + assert_equal :homebrew, owner.channel + assert_equal "ivankuznetsov/hive/hive", owner.details["formula"] + assert_equal %w[brew upgrade] + [ "ivankuznetsov/hive/hive" ], owner.updater + assert_equal %w[brew uninstall] + [ "ivankuznetsov/hive/hive" ], owner.uninstaller + ensure + ENV["PATH"] = old_path + end + end + end + + def test_unknown_without_signals + with_data_home do + owner = Hive::InstallProvenance.detect(command_path: "/tmp/unrelated-binary") + assert_equal :unknown, owner.channel + assert_nil owner.updater + end + end + + def test_homebrew_cellar_path + path = "/opt/homebrew/Cellar/hive/0.1.0/bin/hive" + # Stub brew absence: detect_homebrew returns nil without brew, but + # Cellar path alone is enough when brew exists. Force the path check + # by stubbing command -v brew via a fake owner call path. + # Unit-level: path pattern is covered when brew is present; when not, + # channel is unknown — both are valid. + owner = Hive::InstallProvenance.detect(command_path: path) + assert_includes %i[homebrew unknown bash ambiguous], owner.channel + end +end diff --git a/test/unit/packaging/aur_pkgbuild_test.rb b/test/unit/packaging/aur_pkgbuild_test.rb new file mode 100644 index 00000000..d0e8923f --- /dev/null +++ b/test/unit/packaging/aur_pkgbuild_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "test_helper" +require "erb" +require "json" +require "open3" +require "digest" + +class AurPkgbuildTest < Minitest::Test + def test_pkgbuild_template + template = File.read(File.expand_path("../../../packaging/aur/PKGBUILD.erb", __dir__)) + version = "0.1.0" + url = "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-linux-x86_64.tar.gz" + sha256 = "b" * 64 + body = ERB.new(template).result(binding) + assert_includes body, "pkgname=hive-bin" + assert_includes body, "pkgver=0.1.0" + assert_includes body, "provides=('hive')" + assert_includes body, "conflicts=('hive')" + refute_includes body, "ruby" + assert_includes body, "usr/bin/hv" + end + + def test_install_hook_has_no_user_state_mutation + hook = File.read(File.expand_path("../../../packaging/aur/hive.install", __dir__)) + refute_match(/systemctl --user enable/, hook) + refute_match(/LaunchAgents/, hook) + refute_match(/mkdir.*\.config\/hive/, hook) + end + + + def test_render_script_emits_publishable_pkgbuild_and_srcinfo + Dir.mktmpdir("hive-aur-metadata") do |dir| + manifest = File.join(dir, "manifest.json") + digest = "c" * 64 + File.write(manifest, JSON.generate( + "version" => "0.1.0", + "assets" => [ { + "platform" => "linux", + "arch" => "x86_64", + "url" => "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-linux-x86_64.tar.gz", + "sha256" => digest + } ] + )) + script = File.expand_path("../../../scripts/render-package-metadata", __dir__) + _out, err, status = Open3.capture3("bash", script, manifest, File.join(dir, "out")) + assert status.success?, err + root = File.join(dir, "out", "aur", "hive-bin") + assert File.exist?(File.join(root, "PKGBUILD")) + srcinfo = File.read(File.join(root, ".SRCINFO")) + assert_includes srcinfo, "pkgname = hive-bin" + assert_includes srcinfo, "sha256sums = #{digest}" + end + end +end diff --git a/test/unit/packaging/hive_skills_test.rb b/test/unit/packaging/hive_skills_test.rb new file mode 100644 index 00000000..cd3ba5e3 --- /dev/null +++ b/test/unit/packaging/hive_skills_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" + +class HiveSkillsPackageTest < Minitest::Test + def root + File.expand_path("../../../packaging/hive-skills", __dir__) + end + + def test_validate_manifests + out, err, st = Open3.capture3("ruby", File.join(root, "test/validate_manifests.rb")) + assert st.success?, "#{err}\n#{out}" + assert_match(/ok/, out) + end + + def test_skill_has_hv_fallback + skill = File.read(File.join(root, "plugins/hive/skills/hive/SKILL.md")) + assert_match(/\bhv\b/, skill) + assert_match(/command -v \"\$1\"/, skill) + assert_match(/is_hive_pipeline/, skill) + assert_operator skill.index("is_hive_pipeline hive"), :<, skill.index("is_hive_pipeline hv") + assert_match(/help/, skill) + end + + + def test_npm_pack_contains_only_the_shared_discovery_payload + skip "npm unavailable" unless system("npm", "--version", out: File::NULL, err: File::NULL) + + out, err, st = Open3.capture3("ruby", File.join(root, "test/validate_package.rb")) + assert st.success?, "#{err}\n#{out}" + assert_match(/contains/, out) + end +end diff --git a/test/unit/packaging/homebrew_formula_test.rb b/test/unit/packaging/homebrew_formula_test.rb new file mode 100644 index 00000000..ad88f987 --- /dev/null +++ b/test/unit/packaging/homebrew_formula_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "open3" +require "erb" +require "digest" + +class HomebrewFormulaTest < Minitest::Test + include HiveTestHelper + + def test_template_renders_pinned_url_and_sha + template = File.read(File.expand_path("../../../packaging/homebrew/hive.rb.erb", __dir__)) + b = binding + url = "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-macos-arm64.tar.gz" + version = "0.1.0" + sha256 = "a" * 64 + formula = ERB.new(template).result(b) + assert_includes formula, url + assert_includes formula, version + assert_includes formula, sha256 + assert_includes formula, 'bin.install_symlink "hive" => "hv"' + refute_includes formula, "/latest/" + end + + def test_render_script_from_manifest + with_tmp_dir do |dir| + dist = File.join(dir, "dist") + FileUtils.mkdir_p(dist) + # Build a real archive via package-release + env = { "DIST_DIR" => dist, "PATH" => ENV["PATH"], "HOME" => dir, "HIVE_ALLOW_SOURCE_BUNDLE" => "1" } + scripts = File.expand_path("../../../scripts", __dir__) + _o, err, st = Open3.capture3(env, "bash", File.join(scripts, "package-release"), "v0.1.0", "deadbee") + assert st.success?, err + + # Ensure a macos asset exists for formula render — inject one if linux-only host. + manifest = JSON.parse(File.read(File.join(dist, "manifest.json"))) + unless manifest["assets"].any? { |a| a["platform"] == "macos" } + sample = manifest["assets"].first.dup + sample["platform"] = "macos" + sample["arch"] = "arm64" + sample["filename"] = "hive-macos-arm64.tar.gz" + sample["url"] = sample["url"].sub("linux-x86_64", "macos-arm64") + File.write(File.join(dist, sample["filename"]), "fake") + sample["sha256"] = Digest::SHA256.file(File.join(dist, sample["filename"])).hexdigest + sample["size"] = File.size(File.join(dist, sample["filename"])) + manifest["assets"] << sample + File.write(File.join(dist, "manifest.json"), JSON.pretty_generate(manifest)) + end + + out_dir = File.join(dir, "pkg") + _o, err, st = Open3.capture3("bash", File.join(scripts, "render-package-metadata"), + File.join(dist, "manifest.json"), out_dir) + assert st.success?, err + formula = File.read(File.join(out_dir, "homebrew-tap", "Formula", "hive.rb")) + assert_match(/sha256 "/, formula) + refute_includes formula, "/latest/" + end + end +end diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb new file mode 100644 index 00000000..66d98d9f --- /dev/null +++ b/test/unit/paths_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/paths" +require "fileutils" + +class PathsTest < Minitest::Test + include HiveTestHelper + + def with_isolated_home + with_tmp_dir do |home| + old = capture_env(%w[HOME HIVE_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR]) + ENV["HOME"] = home + ENV.delete("HIVE_HOME") + ENV.delete("XDG_CONFIG_HOME") + ENV.delete("XDG_DATA_HOME") + ENV.delete("XDG_STATE_HOME") + ENV.delete("XDG_CACHE_HOME") + ENV.delete("XDG_RUNTIME_DIR") + begin + yield home + ensure + restore_env(old) + end + end + end + + def capture_env(keys) + keys.to_h { |k| [k, ENV[k]] } + end + + def restore_env(old) + old.each do |k, v| + if v.nil? + ENV.delete(k) + else + ENV[k] = v + end + end + end + + def test_xdg_vars_point_at_hive_subdirs + with_isolated_home do |home| + config = File.join(home, "cfg") + data = File.join(home, "data") + state = File.join(home, "state") + cache = File.join(home, "cache") + runtime = File.join(home, "run") + FileUtils.mkdir_p([config, data, state, cache, runtime]) + + ENV["XDG_CONFIG_HOME"] = config + ENV["XDG_DATA_HOME"] = data + ENV["XDG_STATE_HOME"] = state + ENV["XDG_CACHE_HOME"] = cache + ENV["XDG_RUNTIME_DIR"] = runtime + + assert_equal File.join(config, "hive"), Hive::Paths.config_home + assert_equal File.join(data, "hive"), Hive::Paths.data_home + assert_equal File.join(state, "hive"), Hive::Paths.state_home + assert_equal File.join(cache, "hive"), Hive::Paths.cache_home + assert_equal File.join(runtime, "hive"), Hive::Paths.runtime_dir + + [Hive::Paths.config_home, Hive::Paths.data_home, Hive::Paths.state_home, + Hive::Paths.cache_home, Hive::Paths.runtime_dir].each do |dir| + assert File.directory?(dir) + mode = File.stat(dir).mode & 0o777 + assert_equal 0o700, mode, "expected 0700 on #{dir}, got #{mode.to_s(8)}" + end + end + end + + def test_defaults_when_xdg_unset + with_isolated_home do |home| + assert_equal File.join(home, ".config", "hive"), Hive::Paths.config_home + assert_equal File.join(home, ".local", "share", "hive"), Hive::Paths.data_home + assert_equal File.join(home, ".local", "state", "hive"), Hive::Paths.state_home + assert_equal File.join(home, ".cache", "hive"), Hive::Paths.cache_home + end + end + + def test_relative_xdg_raises_config_error + with_isolated_home do + ENV["XDG_CONFIG_HOME"] = "relative-config" + err = assert_raises(Hive::ConfigError) { Hive::Paths.config_home } + assert_match(/absolute path/, err.message) + end + end + + def test_hive_home_honors_env_override + with_isolated_home do |home| + override = File.join(home, "override-home") + FileUtils.mkdir_p(override) + ENV["HIVE_HOME"] = override + assert_equal override, Hive::Paths.hive_home + assert_equal File.join(override, "config.yml"), Hive::Paths.global_registry_path + end + end + + def test_write_private_file_is_0600 + with_isolated_home do |home| + path = File.join(home, ".config", "hive", "config.yml") + Hive::Paths.write_private_file(path, "ok: true\n") + assert_equal "ok: true\n", File.read(path) + mode = File.stat(path).mode & 0o777 + assert_equal 0o600, mode + end + end +end diff --git a/test/unit/platform_test.rb b/test/unit/platform_test.rb new file mode 100644 index 00000000..9fe30561 --- /dev/null +++ b/test/unit/platform_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/platform" + +class PlatformTest < Minitest::Test + def with_platform(os:, arch:, distro: nil, version: nil, macos_major: nil) + singleton = Hive::Platform.singleton_class + originals = %i[os_family arch linux_distro linux_version macos_major].to_h do |name| + [ name, Hive::Platform.method(name) ] + end + singleton.define_method(:os_family) { os } + singleton.define_method(:arch) { arch } + singleton.define_method(:linux_distro) { distro } + singleton.define_method(:linux_version) { version } + singleton.define_method(:macos_major) { macos_major } + yield + ensure + originals&.each { |name, implementation| singleton.define_method(name, implementation) } + end + + def test_os_family_is_known_on_this_host + assert_includes %i[macos linux windows unknown], Hive::Platform.os_family + end + + def test_arch_is_known_on_this_host + refute_equal :unknown, Hive::Platform.arch + end + + def test_classify_returns_tier_struct + tier = Hive::Platform.classify + assert_kind_of Hive::Platform::Tier, tier + assert_includes [ 1, 2, 3 ], tier.level + refute_empty tier.message + end + + def test_release_asset_triplet_format + triplet = Hive::Platform.release_asset_triplet + assert_match(/\A[a-z0-9]+-[a-z0-9_]+\z/, triplet) + end + + def test_reject_if_unsupported_passes_on_supported_host + # This CI host is Linux x86_64 → tier-1 or tier-2. + tier = Hive::Platform.reject_if_unsupported! + assert tier.level <= 2 + end + + def test_exact_tier_one_boundaries + with_platform(os: :macos, arch: :arm64, macos_major: 14) do + assert_equal 1, Hive::Platform.classify.level + end + with_platform(os: :macos, arch: :arm64, macos_major: nil) do + assert_equal 2, Hive::Platform.classify.level + end + with_platform(os: :macos, arch: :arm64, macos_major: 13) do + assert_equal 2, Hive::Platform.classify.level + end + with_platform(os: :linux, arch: :x86_64, distro: "ubuntu", version: "22.04") do + assert_equal 1, Hive::Platform.classify.level + end + with_platform(os: :linux, arch: :x86_64, distro: "ubuntu", version: "20.04") do + assert_equal 2, Hive::Platform.classify.level + end + %w[debian manjaro endeavouros].each do |distro| + with_platform(os: :linux, arch: :x86_64, distro: distro, version: "99") do + assert_equal 2, Hive::Platform.classify.level + end + end + with_platform(os: :linux, arch: :x86_64, distro: "arch") do + assert_equal 1, Hive::Platform.classify.level + end + end +end diff --git a/test/unit/project/cleanup_test.rb b/test/unit/project/cleanup_test.rb new file mode 100644 index 00000000..f046461e --- /dev/null +++ b/test/unit/project/cleanup_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/project/scaffold" +require "hive/project/cleanup" + +class ProjectCleanupTest < Minitest::Test + include HiveTestHelper + + def test_removes_only_hash_matching + with_tmp_dir do |dir| + Hive::Project::Scaffold.new(dir).call + File.write(File.join(dir, ".hive", "README.md"), "changed by user\n") + # config.example.yml stays unmodified + + plan = Hive::Project::Cleanup.new(dir).preview + assert plan.preserved.any? { |p| p.end_with?("README.md") } + assert plan.removed.any? { |p| p.end_with?("config.example.yml") } + + result = Hive::Project::Cleanup.new(dir).call!(confirm: true) + assert File.exist?(File.join(dir, ".hive", "README.md")) + refute File.exist?(File.join(dir, ".hive", "config.example.yml")) + assert_includes result.removed, ".hive/config.example.yml" + end + end + + def test_symlink_escape_preserved + with_tmp_dir do |dir| + Hive::Project::Scaffold.new(dir).call + outside = File.join(dir, "outside.txt") + File.write(outside, "secret\n") + # Replace a generated file with a symlink escaping ownership intent. + target = File.join(dir, ".hive", "config.example.yml") + FileUtils.rm_f(target) + File.symlink(outside, target) + + plan = Hive::Project::Cleanup.new(dir).preview + refute_includes plan.removed, ".hive/config.example.yml" + end + end + + def test_edited_manifest_cannot_claim_an_arbitrary_project_file + with_tmp_dir do |dir| + Hive::Project::Scaffold.new(dir).call + user_file = File.join(dir, "notes.txt") + File.write(user_file, "keep me\n") + + manifest = Hive::Project::Manifest.load(dir) + manifest.data.fetch("generated")["notes.txt"] = { + "sha256" => Digest::SHA256.file(user_file).hexdigest, + "bytes" => File.size(user_file) + } + manifest.save! + + plan = Hive::Project::Cleanup.new(dir).preview + refute_includes plan.removed, "notes.txt" + assert_includes plan.preserved, "notes.txt" + + Hive::Project::Cleanup.new(dir).call!(confirm: true) + assert_equal "keep me\n", File.read(user_file) + end + end +end diff --git a/test/unit/project/manifest_test.rb b/test/unit/project/manifest_test.rb new file mode 100644 index 00000000..01780549 --- /dev/null +++ b/test/unit/project/manifest_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/project/manifest" +require "digest" + +class ProjectManifestTest < Minitest::Test + include HiveTestHelper + + def test_record_and_unmodified + with_tmp_dir do |dir| + manifest = Hive::Project::Manifest.new(dir) + content = "hello\n" + rel = ".hive/README.md" + FileUtils.mkdir_p(File.join(dir, ".hive")) + File.write(File.join(dir, rel), content) + digest = manifest.record!(rel, content) + assert_equal Digest::SHA256.hexdigest(content), digest + manifest.save! + assert File.exist?(Hive::Project::Manifest.path_for(dir)) + assert manifest.unmodified?(rel) + + File.write(File.join(dir, rel), "changed\n") + refute manifest.unmodified?(rel) + assert_includes manifest.preserved_paths, rel + end + end + + def test_load_roundtrip + with_tmp_dir do |dir| + m = Hive::Project::Manifest.new(dir) + m.record!(".hive/README.md", "x") + m.save! + loaded = Hive::Project::Manifest.load(dir) + assert_equal Hive::VERSION, loaded.template_version + assert loaded.generated[".hive/README.md"] + end + end +end diff --git a/test/unit/project/scaffold_test.rb b/test/unit/project/scaffold_test.rb new file mode 100644 index 00000000..19129339 --- /dev/null +++ b/test/unit/project/scaffold_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/project/scaffold" + +class ProjectScaffoldTest < Minitest::Test + include HiveTestHelper + + def test_creates_hive_scaffold + with_tmp_dir do |dir| + result = Hive::Project::Scaffold.new(dir).call + assert File.directory?(File.join(dir, ".hive")) + assert File.exist?(File.join(dir, ".hive", "manifest.yml")) + assert File.exist?(File.join(dir, ".hive", "README.md")) + assert result.created.any? + assert_empty result.conflicts + end + end + + def test_idempotent_rerun + with_tmp_dir do |dir| + first = Hive::Project::Scaffold.new(dir).call + second = Hive::Project::Scaffold.new(dir).call + assert first.created.any? + assert_empty second.created + assert second.skipped.any? + end + end + + def test_preserves_user_modified_file + with_tmp_dir do |dir| + Hive::Project::Scaffold.new(dir).call + path = File.join(dir, ".hive", "README.md") + File.write(path, "my custom notes\n") + result = Hive::Project::Scaffold.new(dir).call + assert_equal "my custom notes\n", File.read(path) + assert_includes result.conflicts, ".hive/README.md" + end + end + + def test_unclaimed_hive_dir_refuses + with_tmp_dir do |dir| + FileUtils.mkdir_p(File.join(dir, ".hive")) + File.write(File.join(dir, ".hive", "other.txt"), "not ours\n") + err = assert_raises(Hive::Error) { Hive::Project::Scaffold.new(dir).call } + assert_match(/without a Hive manifest/, err.message) + end + end +end diff --git a/test/unit/release/manifest_test.rb b/test/unit/release/manifest_test.rb new file mode 100644 index 00000000..e004a786 --- /dev/null +++ b/test/unit/release/manifest_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "open3" + +class ReleaseManifestTest < Minitest::Test + include HiveTestHelper + + def scripts + File.expand_path("../../../scripts", __dir__) + end + + def test_package_release_roundtrip + with_tmp_dir do |dir| + dist = File.join(dir, "dist") + env = { "DIST_DIR" => dist, "PATH" => ENV["PATH"], "HOME" => dir, "HIVE_ALLOW_SOURCE_BUNDLE" => "1" } + out, err, status = Open3.capture3(env, "bash", File.join(scripts, "package-release"), "v0.1.0", "abc1234") + assert status.success?, "package-release failed: #{err}\n#{out}" + assert File.exist?(File.join(dist, "manifest.json")) + assert File.exist?(File.join(dist, "SHA256SUMS")) + assert File.exist?(File.join(dist, "install.sh")) + assert_match(/install\.sh/, File.read(File.join(dist, "SHA256SUMS"))) + + manifest = JSON.parse(File.read(File.join(dist, "manifest.json"))) + assert_equal 1, manifest["schema_version"] + assert_equal "v0.1.0", manifest["tag"] + assert_equal "0.1.0", manifest["version"] + refute_empty manifest["assets"] + manifest["assets"].each do |a| + refute_includes a["url"], "/latest/" + assert_match(/\A[a-f0-9]{64}\z/, a["sha256"]) + end + + _o, _e, st = Open3.capture3(env, "bash", File.join(scripts, "verify-release"), dist) + assert st.success? + + # One-byte mutation fails. + archive = Dir[File.join(dist, "hive-*.tar.gz")].first + File.open(archive, "ab") { |f| f.write("x") } + _o, _e, st2 = Open3.capture3(env, "bash", File.join(scripts, "verify-release"), dist) + refute st2.success? + end + end + + + def test_equivalent_builds_are_byte_for_byte_deterministic + with_tmp_dir do |dir| + epoch = "1700000000" + manifests = [] + archives = [] + 2.times do |index| + dist = File.join(dir, "dist-#{index}") + env = { + "DIST_DIR" => dist, + "PATH" => ENV["PATH"], + "HOME" => dir, + "HIVE_ALLOW_SOURCE_BUNDLE" => "1", + "SOURCE_DATE_EPOCH" => epoch + } + out, err, status = Open3.capture3( + env, "bash", File.join(scripts, "package-release"), "v0.1.0", "abc1234" + ) + assert status.success?, "#{err}\n#{out}" + manifests << File.binread(File.join(dist, "manifest.json")) + archives << File.binread(Dir[File.join(dist, "hive-*.tar.gz")].first) + end + assert_equal manifests.first, manifests.last + assert_equal archives.first, archives.last + end + end + + def test_release_build_refuses_missing_tebako_without_test_override + with_tmp_dir do |dir| + env = { "DIST_DIR" => File.join(dir, "dist"), "PATH" => ENV["PATH"], "HOME" => dir } + _out, err, status = Open3.capture3(env, "bash", File.join(scripts, "build-release")) + refute status.success? + assert_match(/tebako is required/, err) + end + end + + def test_manifest_schema_file_exists + path = File.expand_path("../../../release/manifest.schema.json", __dir__) + assert File.exist?(path) + schema = JSON.parse(File.read(path)) + assert_equal 1, schema.dig("properties", "schema_version", "const") + end +end diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb index b5ce1b18..c85e0de0 100644 --- a/test/unit/schema_files_test.rb +++ b/test/unit/schema_files_test.rb @@ -1038,7 +1038,7 @@ class SchemaFilesTest < Minitest::Test # The producer's exhaustive key set (kept in sync with # Hive::Commands::Daemon#status_daemon's JSON.generate call). producer_required = %w[ - schema schema_version ok running pid uptime_sec pid_file log_file + schema schema_version ok running pid uptime_sec pid_file log_file service ].sort assert_equal producer_required, schema_required, "schema/producer required-key drift in hive-daemon-status.v1.json" diff --git a/test/unit/service/launchd_test.rb b/test/unit/service/launchd_test.rb new file mode 100644 index 00000000..73dd981f --- /dev/null +++ b/test/unit/service/launchd_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/service/launchd" + +class ServiceLaunchdTest < Minitest::Test + include HiveTestHelper + + def test_renders_valid_plist + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: "/usr/local/bin/hive", + state_dir: dir, + runtime_dir: File.join(dir, "run"), + log_path: File.join(dir, "daemon.log"), + env: { "HIVE_HOME" => dir } + ) + adapter = Hive::Service::Launchd.new(contract) + plist = adapter.render + assert_includes plist, "dev.hive.daemon" + assert_includes plist, "/usr/local/bin/hive" + assert_includes plist, "daemon" + assert_includes plist, "run" + assert_includes plist, "RunAtLoad" + assert_includes plist, "" + refute_match(/RunAtLoad<\/key>\s*/, plist) + end + end + + def test_write_definition_idempotent + with_tmp_dir do |dir| + # Redirect LaunchAgents into tmp by subclassing path via HOME. + old_home = ENV["HOME"] + ENV["HOME"] = dir + begin + contract = Hive::Service::Adapter::Contract.new( + command_path: "#{dir}/bin/hive", + state_dir: dir, + runtime_dir: File.join(dir, "run"), + log_path: File.join(dir, "daemon.log"), + env: {} + ) + adapter = Hive::Service::Launchd.new(contract) + path = adapter.definition_path + content = adapter.render + adapter.send(:write_definition, path, content) + assert File.exist?(path) + assert_equal content, File.read(path) + ensure + ENV["HOME"] = old_home + end + end + end + + def test_prompt_declined_does_not_start + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: "hive", state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::Launchd.new(contract) + input = StringIO.new("n\n") + def input.tty? = true + output = StringIO.new + result = adapter.prompt_start_if_interactive!(input: input, output: output) + assert_equal :declined, result + assert_match(/not started/, output.string) + end + end + + def test_non_interactive_does_not_autostart + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: "hive", state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::Launchd.new(contract) + input = StringIO.new + def input.tty? = false + output = StringIO.new + result = adapter.prompt_start_if_interactive!(input: input, output: output) + assert_equal :non_interactive, result + assert_match(/non-interactive/, output.string) + end + end +end diff --git a/test/unit/service/systemd_user_test.rb b/test/unit/service/systemd_user_test.rb new file mode 100644 index 00000000..4e98da12 --- /dev/null +++ b/test/unit/service/systemd_user_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/service/systemd_user" + +class ServiceSystemdUserTest < Minitest::Test + include HiveTestHelper + + def test_renders_valid_unit + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: "/usr/bin/hive", + state_dir: dir, + runtime_dir: File.join(dir, "run"), + log_path: File.join(dir, "daemon.log"), + env: { "HIVE_HOME" => dir, "XDG_STATE_HOME" => dir } + ) + adapter = Hive::Service::SystemdUser.new(contract) + unit = adapter.render + assert_includes unit, "[Unit]" + assert_includes unit, "[Service]" + assert_includes unit, "[Install]" + assert_includes unit, "ExecStart=/usr/bin/hive daemon run" + assert_includes unit, "WantedBy=default.target" + assert_includes unit, "Restart=on-failure" + assert_includes unit, %(Environment="HIVE_HOME=#{dir}") + assert_includes unit, %(Environment="XDG_STATE_HOME=#{dir}") + end + end + + def test_start_propagates_enable_failure + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: File.join(dir, "hive"), state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + adapter.define_singleton_method(:systemd_available?) { true } + calls = [] + adapter.define_singleton_method(:system) do |*args, **_opts| + calls << args + false + end + error = assert_raises(Hive::Error) { adapter.start! } + assert_match(/enable hive\.service failed/, error.message) + assert_equal 1, calls.size + end + end + + def test_nonzero_user_bus_probe_is_unavailable + with_tmp_dir do |dir| + systemctl = File.join(dir, "systemctl") + File.write(systemctl, "#!/bin/sh\necho 'Failed to connect to bus' >&2\nexit 1\n") + FileUtils.chmod(0o755, systemctl) + old_path = ENV["PATH"] + ENV["PATH"] = dir + contract = Hive::Service::Adapter::Contract.new( + command_path: "hive", state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + refute adapter.send(:systemd_available?) + ensure + ENV["PATH"] = old_path + end + end + + def test_definition_path_respects_xdg_config + with_tmp_dir do |dir| + old = ENV["XDG_CONFIG_HOME"] + ENV["XDG_CONFIG_HOME"] = File.join(dir, "cfg") + begin + contract = Hive::Service::Adapter::Contract.new( + command_path: "hive", state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + assert_equal File.join(dir, "cfg", "systemd", "user", "hive.service"), + adapter.definition_path + ensure + old.nil? ? ENV.delete("XDG_CONFIG_HOME") : ENV["XDG_CONFIG_HOME"] = old + end + end + end + + def test_register_writes_unit_without_systemd + with_tmp_dir do |dir| + old_cfg = ENV["XDG_CONFIG_HOME"] + old_path = ENV["PATH"] + ENV["XDG_CONFIG_HOME"] = File.join(dir, "cfg") + # Empty PATH so systemctl is "missing" → graceful fallback. + ENV["PATH"] = dir + begin + contract = Hive::Service::Adapter::Contract.new( + command_path: File.join(dir, "hive"), + state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + # Force systemd_available? false + adapter.define_singleton_method(:systemd_available?) { false } + path = adapter.register! + assert File.exist?(path) + assert_includes File.read(path), "ExecStart=" + ensure + old_cfg.nil? ? ENV.delete("XDG_CONFIG_HOME") : ENV["XDG_CONFIG_HOME"] = old_cfg + ENV["PATH"] = old_path + end + end + end + + def test_stop_and_unregister_are_idempotent_without_a_definition + with_tmp_dir do |dir| + contract = Hive::Service::Adapter::Contract.new( + command_path: File.join(dir, "hive"), state_dir: dir, runtime_dir: dir, + log_path: File.join(dir, "d.log"), env: {} + ) + adapter = Hive::Service::SystemdUser.new(contract) + definition = File.join(dir, "missing.service") + adapter.define_singleton_method(:definition_path) { definition } + adapter.define_singleton_method(:systemd_available?) { true } + adapter.define_singleton_method(:system) { |*| raise "systemctl must not run" } + + assert adapter.stop! + assert adapter.unregister! + end + end +end diff --git a/test/unit/state_migration_test.rb b/test/unit/state_migration_test.rb new file mode 100644 index 00000000..909cf158 --- /dev/null +++ b/test/unit/state_migration_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/state_migration" +require "hive/paths" + +class StateMigrationTest < Minitest::Test + include HiveTestHelper + + def test_no_legacy_is_noop + with_tmp_dir do |dir| + dest = File.join(dir, "state") + FileUtils.mkdir_p(dest) + m = Hive::StateMigration.new( + legacy_global: File.join(dir, "missing-global"), + legacy_state: File.join(dir, "missing-state"), + destination: dest + ) + result = m.call + refute result.migrated + assert_match(/no legacy/, result.skipped_reason) + end + end + + def test_copies_and_retains_source + with_tmp_dir do |dir| + legacy = File.join(dir, "Dev", "hive") + FileUtils.mkdir_p(legacy) + File.write(File.join(legacy, "config.yml"), "registered_projects: []\n") + File.write(File.join(legacy, "notes.txt"), "keep me\n") + dest = File.join(dir, "xdg-state") + FileUtils.mkdir_p(dest) + + m = Hive::StateMigration.new( + legacy_global: legacy, + legacy_state: File.join(dir, "nope"), + destination: dest, + config_destination: File.join(dir, "config", "hive", "config.yml") + ) + result = m.call(copy: true) + assert result.migrated + assert File.exist?(File.join(legacy, "notes.txt")), "legacy source must be retained" + imported = File.join(dest, "legacy-import", "hive", "notes.txt") + assert File.exist?(imported) + assert_equal "keep me\n", File.read(imported) + assert File.exist?(File.join(dest, ".migration.yml")) + active_config = YAML.safe_load(File.read(File.join(dir, "config", "hive", "config.yml"))) + assert_equal [], active_config["registered_projects"] + + # Second run is a no-op. + second = m.call(copy: true) + refute second.migrated + end + end + + + def test_adopts_legacy_registry_and_daemon_config_into_active_xdg_config + with_tmp_dir do |dir| + legacy = File.join(dir, "Dev", "hive") + FileUtils.mkdir_p(legacy) + File.write(File.join(legacy, "config.yml"), { + "registered_projects" => [ { "name" => "old", "path" => "/tmp/old" } ], + "daemon" => { "enabled" => true } + }.to_yaml) + active = File.join(dir, "config", "hive", "config.yml") + Hive::Paths.write_private_file(active, { + "provider" => "openai", + "registered_projects" => [ { "name" => "new", "path" => "/tmp/new" } ] + }.to_yaml) + + migration = Hive::StateMigration.new( + legacy_global: legacy, + legacy_state: File.join(dir, "missing"), + destination: File.join(dir, "state"), + config_destination: active + ) + migration.call(copy: true) + + adopted = YAML.safe_load(File.read(active)) + assert_equal %w[new old], adopted.fetch("registered_projects").map { |p| p.fetch("name") } + assert_equal true, adopted.dig("daemon", "enabled") + assert_equal "openai", adopted["provider"] + assert File.exist?(File.join(legacy, "config.yml")) + end + end +end diff --git a/test/unit/user_config_test.rb b/test/unit/user_config_test.rb new file mode 100644 index 00000000..c37cff9b --- /dev/null +++ b/test/unit/user_config_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" +require "hive/user_config" +require "hive/paths" + +class UserConfigTest < Minitest::Test + include HiveTestHelper + + def with_user_config_home + with_tmp_dir do |home| + old = { + "HOME" => ENV["HOME"], + "HIVE_HOME" => ENV["HIVE_HOME"], + "XDG_CONFIG_HOME" => ENV["XDG_CONFIG_HOME"] + } + ENV["HOME"] = home + ENV.delete("HIVE_HOME") + ENV["XDG_CONFIG_HOME"] = File.join(home, "config") + begin + yield home + ensure + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + end + end + end + + def test_writes_0600_with_env_reference + with_user_config_home do + path = Hive::UserConfig.ensure_defaults!( + provider: "openai", + model: "gpt-5", + api_key_env: "OPENAI_API_KEY" + ) + assert File.exist?(path) + mode = File.stat(path).mode & 0o777 + assert_equal 0o600, mode + data = YAML.safe_load(File.read(path)) + assert_equal "openai", data["provider"] + assert_equal "OPENAI_API_KEY", data["api_key_env"] + refute data.key?("api_key") + end + end + + def test_rejects_plaintext_secret + with_user_config_home do + err = assert_raises(Hive::ConfigError) do + Hive::UserConfig.write!("provider" => "x", "note" => "sk-abcdefghijklmnopqrstuv") + end + assert_match(/plaintext secret/, err.message) + + err2 = assert_raises(Hive::ConfigError) do + Hive::UserConfig.write!("provider" => "x", "api_key" => "sk-abcdefghijklmnopqrstuv", "model" => "m") + end + assert_match(/plaintext secret/, err2.message) + refute File.exist?(Hive::UserConfig.path) + end + end + + def test_redact + assert_equal "[REDACTED]", Hive::UserConfig.redact("sk-abcdefghijklmnopqrstuv") + end + + def test_idempotent_ensure + with_user_config_home do + Hive::UserConfig.ensure_defaults!(provider: "a", model: "m1", api_key_env: "A_API_KEY") + Hive::UserConfig.ensure_defaults!(provider: "b", model: "m2", api_key_env: "B_API_KEY") + data = Hive::UserConfig.load + assert_equal "a", data["provider"] + assert_equal "m1", data["model"] + end + end + + def test_non_interactive_first_run_requires_explicit_configuration_without_writing + with_user_config_home do + error = assert_raises(Hive::ConfigError) do + Hive::UserConfig.ensure_configured!(input: StringIO.new) + end + assert_match(/--provider/, error.message) + refute File.exist?(Hive::UserConfig.path) + end + end + + def test_interactive_first_run_collects_references_not_secrets + with_user_config_home do + input = StringIO.new("anthropic\nclaude-sonnet-4-5\nANTHROPIC_API_KEY\n") + input.define_singleton_method(:tty?) { true } + output = StringIO.new + Hive::UserConfig.ensure_configured!(input: input, output: output) + data = Hive::UserConfig.load + assert_equal "anthropic", data["provider"] + assert_equal "claude-sonnet-4-5", data["model"] + assert_equal "ANTHROPIC_API_KEY", data["api_key_env"] + refute_match(/sk-|API key value/i, File.read(Hive::UserConfig.path)) + end + end + + def test_repairs_blank_values_in_a_partial_config + with_user_config_home do + Hive::UserConfig.write!( + "provider" => "", + "model" => "", + "api_key_env" => "" + ) + Hive::UserConfig.ensure_configured!( + provider: "openai", model: "gpt-5", api_key_env: "OPENAI_API_KEY", + input: StringIO.new + ) + + data = Hive::UserConfig.load + assert_equal "openai", data["provider"] + assert_equal "gpt-5", data["model"] + assert_equal "OPENAI_API_KEY", data["api_key_env"] + end + end +end diff --git a/wiki/cli.md b/wiki/cli.md index 179a4998..1e584023 100644 --- a/wiki/cli.md +++ b/wiki/cli.md @@ -3,7 +3,7 @@ title: CLI Surface type: api source: bin/hive, lib/hive/cli.rb created: 2026-04-25 -updated: 2026-05-14 +updated: 2026-07-15 tags: [cli, api] --- @@ -17,11 +17,13 @@ tags: [cli, api] | Command | Synopsis | Routes to | Page | |---------|----------|-----------|------| -| `hive init [PROJECT_PATH]` | Bootstrap `.hive-state` orphan branch + worktree in a git project | `Hive::Commands::Init` | [[commands/init]] | +| `hive init [PROJECT_PATH] [--provider NAME --model NAME --api-key-env ENV_NAME]` | Create or repair user configuration, `.hive-state`, registry, scaffold, migration, and service registration | `Hive::Commands::Init` | [[commands/init]] | +| `hive update` | Detect the owning install channel, run its collision-safe updater, verify the resulting command version, and preserve service state | `Hive::Commands::Update` | [[distribution]] | +| `hive uninstall [--yes] [--clean-project]` | Resolve ownership before mutation, remove service registration, delegate binary removal, and optionally remove allowlisted unmodified scaffold | `Hive::Commands::Uninstall` | [[distribution]] | | `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. Public `start` / `stop` / `status` control the registered launchd or `systemd --user` service; `run` is its foreground entry. `reload` / `tail` and project `enable` / `disable` remain available. | `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 +41,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 global registry (`$XDG_CONFIG_HOME/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 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` | — | @@ -48,7 +50,9 @@ tags: [cli, api] - `new_task` is mapped to the user-visible `new` (Thor reserves `new`). - `run_task` is mapped to `run`. - Stage verbs use `--from` for source-stage disambiguation because the verb already implies the target stage. -- `init` accepts `--force` (skip clean-tree check). +- `init` accepts `--force` plus explicit first-run `--provider`, `--model`, and + `--api-key-env` choices. Reruns repair existing initialization rather than + rejecting the `hive/state` branch. - `--json` is a `class_option` honoured by `status`, `run`, `rebase-status`, `approve`, `findings`, `accept-finding`, `reject-finding`, the workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `finalize`, `archive`), `markers clear`, `metrics`, `forget`, `prune`, the `daemon` subcommands (`status`, `stop`, `reload`, `enable`, `disable`), and the `bot` lifecycle subcommands (`status`, `stop`, `reload`). Daemon lifecycle JSON is published as `hive-daemon-status.v1` / `-stop.v1` / `-reload.v1` / `-enroll.v1`; bot lifecycle JSON is published as `hive-bot-status.v1` / `-stop.v1` / `-reload.v1`. Each command with full envelope support emits a typed JSON document on success and a structured error envelope on failure. Workflow verbs emit a single `hive-stage-action` envelope (inner Approve and Run are passed `quiet: true` to avoid double-emission). `rebase-status` emits a sibling read-only `hive-rebase-status` envelope — not validated against `hive-run.v1`. - `bin/hive` rewrites ` --help` / ` -h` into `help ` before Thor dispatch, so the convention agents try first works (without the rewrite, Thor would consume `--help` as the next positional argument). - `bin/hive` handles top-level `--version` / `-v` before Thor dispatch so wrappers can smoke-test the binary without parsing help output. @@ -59,7 +63,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 compatibility code; current init repairs existing projects | legacy `Hive::AlreadyInitialized` callers only | | 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 +97,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` | compatibility class retained for older callers; the current init path does not raise it | 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. @@ -102,5 +106,6 @@ A few stage runners still call `warn`/`exit N` directly for non-bug user errors ## Backlinks - [[architecture]] -- [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/rebase-status]] · [[commands/status]] · [[commands/approve]] · [[commands/findings]] · [[commands/stage_action]] · [[commands/bot]] +- [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/rebase-status]] · [[commands/status]] · [[commands/approve]] · [[commands/findings]] · [[commands/stage_action]] · [[commands/daemon]] · [[commands/bot]] +- [[distribution]] · [[modules/paths]] - [[stages/inbox]] · [[stages/brainstorm]] · [[stages/plan]] · [[stages/execute]] · [[stages/open-pr]] · [[stages/review]] · [[stages/finalize]] · [[stages/done]] diff --git a/wiki/commands/bot.md b/wiki/commands/bot.md index 0b360ad2..bacd719b 100644 --- a/wiki/commands/bot.md +++ b/wiki/commands/bot.md @@ -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 the PID under `$XDG_STATE_HOME/hive`, 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/.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..717d9b90 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-15 tags: [command, daemon, automation, json] --- @@ -19,7 +19,8 @@ GitHub. ## Subcommands ``` -hive daemon start [--detach] [--dry-run] +hive daemon start +hive daemon run [--dry-run] hive daemon stop hive daemon status [--json] hive daemon reload @@ -30,11 +31,12 @@ 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. | -| `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`. | +| `start` | Starts the registered launchd or `systemd --user` service and propagates service-manager failures. If no service is registered, falls back to the legacy direct-process start. Managed start rejects `--detach` / `--dry-run`; those flags belong to the internal `run` entry. | +| `run` | Foreground service-manager entry that acquires the PID file and runs the dispatcher. It accepts `--dry-run` for bounded diagnostics but never detaches from its supervisor. Refuses with exit `75 (TEMPFAIL)` if a live daemon already owns the PID file. | +| `stop` | Stops the registered user service and propagates service-manager failures. If unregistered, uses the direct PID controller, including its idempotent no-PID and stale-PID behavior. With `--json`, emits a `hive-daemon-stop` envelope. | +| `status` | Combines direct-process state with the registered service state. The v1 JSON envelope always includes required `service`, as an adapter object or `null`; process fields remain present. | | `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. | @@ -81,7 +83,7 @@ stage agents. Operators of legacy projects opt in by adding `daemon: { enabled: ## 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 +95,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 +113,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} @@ -123,21 +125,21 @@ new events are caught at CI rather than logged silently. ## Operational notes -- **Day-2 guide is at [[operating]]** — install path (`hive daemon - enable PROJECT|--all`), autostart on Linux (systemd-user) and - macOS (launchd) with sample units in `examples/systemd/` and - `examples/launchd/`, dry-run shakedown, and troubleshooting. -- **First-time rollout:** `hive daemon start --dry-run` for ~24 hours - before going live. Inspect `daemon.log` to validate dispatch - decisions. Then `hive daemon stop` and re-start without `--dry-run`. +- **Day-2 guide is at [[operating]]** — project enrollment, registered + launchd/systemd-user lifecycle, logging, and troubleshooting. +- **Service ownership:** `hive init` renders the unit from the verified stable + `hive`/`hv` command path. The systemd template emits `HIVE_HOME` and all XDG + roots as individual escaped environment entries. +- **Truthful lifecycle:** unavailable user buses, failed enable/start, and failed + stop/unregister operations remain errors. Init and uninstall do not continue + with a success claim or binary removal after those failures. - **Cost runaway response:** `hive daemon stop` is one command. To exclude a single project mid-flight: `hive daemon disable PROJECT` (the next tick honours it automatically). In-flight children continue to completion. -- **macOS / Linux:** `Process.daemon` works on both; the daemon does - not require `systemd`. Sample autostart units ship at - `examples/systemd/hive-daemon.service` (Linux) and - `examples/launchd/hive-daemon.plist` (macOS). +- **macOS / Linux:** normal lifecycle uses the generated LaunchAgent or + `systemd --user` unit. Direct process control is retained only when no service + registration exists. - **Pausing a single task:** edit the state file to remove the terminal marker (e.g., delete ``) so the row no longer classifies as advance-ready. Daemon will skip until you write @@ -147,8 +149,10 @@ new events are caught at CI rather than logged silently. | Subcommand | Code | Condition | |------------|------|-----------| -| `start` | 0 | (foreground daemon exited cleanly) | -| `start` | 75 | Another daemon already running (TEMPFAIL) | +| `start` | 0 | registered service started | +| `start` | 1/70 | service manager unavailable or start failed | +| `run` | 0 | foreground dispatcher exited cleanly | +| `run` | 75 | another daemon already running (TEMPFAIL) | | `stop` | 0 | Always (idempotent) | | `status` | 0 | Daemon is running | | `status` | 1 | Daemon is not running | diff --git a/wiki/commands/forget.md b/wiki/commands/forget.md index 460118f0..63f8bde8 100644 --- a/wiki/commands/forget.md +++ b/wiki/commands/forget.md @@ -7,7 +7,10 @@ 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 global registry (`$XDG_CONFIG_HOME/hive/config.yml`, or the +explicit `HIVE_HOME` override). Inverse of `hive init`. The project's +`.hive-state` directory is not touched. Bulk version: [[commands/prune]]. ## Usage diff --git a/wiki/commands/init.md b/wiki/commands/init.md index ee73c372..40914cab 100644 --- a/wiki/commands/init.md +++ b/wiki/commands/init.md @@ -1,103 +1,107 @@ --- title: hive init type: command -source: lib/hive/commands/init.rb +source: lib/hive/commands/init.rb, lib/hive/user_config.rb, lib/hive/state_migration.rb created: 2026-04-25 -updated: 2026-05-04 -tags: [command, bootstrap, git, prompts] +updated: 2026-07-15 +tags: [command, bootstrap, git, prompts, xdg] --- -**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]` creates or repairs the orphan `hive/state` +worktree, user/provider configuration, project configuration, generated +scaffold, global registry row, legacy adoption, and user-service registration. +The operation is idempotent: rerunning after partial success completes the +missing steps instead of rejecting an existing branch. ## Usage -``` +```text hive init [PROJECT_PATH] [--force] + [--provider NAME] [--model NAME] [--api-key-env ENV_NAME] ``` -`PROJECT_PATH` defaults to `Dir.pwd`. `--force` skips the clean-tree check. +`PROJECT_PATH` defaults to `Dir.pwd`. `--force` skips the clean-tree check when +creating state for the first time. The provider options may instead come from +`HIVE_PROVIDER`, `HIVE_MODEL`, and `HIVE_API_KEY_ENV`. ## Preconditions -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). - -## 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. -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. -4. **Create orphan worktree** via `Hive::GitOps#hive_state_init` (`lib/hive/git_ops.rb:34`): - - `git worktree add --no-checkout --detach /.hive-state ` - - `git -C .hive-state checkout --orphan hive/state` - - `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. - -## Prompt flow (ADR-023) - -On TTY input streams the prompt walks the operator through four sections 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. - -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. - -### Stable-iteration-order contract - -The prompt's choice list is rendered in a documented stable order: -- **Agent profiles**: `claude`, `codex`, `pi` — the order in which `lib/hive/agent_profiles.rb` requires them at boot. `Hive::AgentProfiles.registered_names` returns them in this order. -- **Default reviewers**: `claude-ce-code-review`, `codex-ce-code-review`, `pr-review-toolkit` — the order shipped in `templates/project_config.yml.erb` and surfaced via `Hive::Commands::Init::Prompts::DEFAULT_REVIEWER_NAMES`. - -Reordering either is a **breaking change for scripted automation** that uses index answers — index `1` would silently shift to a different value. Prefer names in scripts. - -### Non-TTY contract - -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 -``` - -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. - -## Default-branch detection - -`GitOps#detect_default_branch` (`lib/hive/git_ops.rb:92`) tries: - -1. `git symbolic-ref refs/remotes/origin/HEAD` → strip `refs/remotes/origin/` prefix. -2. Fallback: `git rev-parse --abbrev-ref HEAD` (if not detached). -3. Fallback: `git config init.defaultBranch`. -4. Final fallback: literal `"master"`. - -This branch is what the orphan worktree is initially based on, and what feature worktrees are branched from. +1. The path must be a git repository and its main checkout, not a feature + worktree. +2. The working tree must be clean unless `--force` is passed. On a rerun where + `hive/state` already exists, the clean-tree bootstrap guard is skipped so + repair can proceed. +3. First-run provider, model, and credential environment-variable name must all + be available. Interactive runs prompt; non-interactive runs fail with an + actionable `Hive::ConfigError` naming the three flags. +4. Hive never asks for or stores the credential value. The configured + environment variable must hold it when provider access is needed. + +## Idempotent steps + +1. **Validate the checkout.** Detect the default branch and protect a new + bootstrap from tracked local changes. +2. **Collect user configuration.** `Hive::UserConfig#ensure_configured!` writes + `$XDG_CONFIG_HOME/hive/config.yml` mode `0600` with `provider`, `model`, and + `api_key_env`. Existing explicit values win. +3. **Adopt legacy state.** `Hive::StateMigration` copies and verifies legacy + trees into `$XDG_STATE_HOME/hive/legacy-import` and non-destructively merges + the legacy `~/Dev/hive/config.yml` registry and daemon consent into the + active XDG config. Legacy sources are retained. +4. **Create or reuse the state worktree.** `GitOps#hive_state_init` creates the + orphan `hive/state` branch and `/.hive-state/`, or returns the + existing attachment on a repair run. +5. **Collect project choices when needed.** A missing project config triggers + the planning/development/reviewer/limit prompt flow. Existing config is not + overwritten and reruns do not repeat those prompts. +6. **Render config and scaffold.** Missing `.hive-state/config.yml` and shipped + `.hive/` scaffold files are materialized; existing operator content is + preserved. +7. **Ignore and register.** `/.hive-state/` is added to the main checkout's + `.gitignore`, and the project row is upserted in + `$XDG_CONFIG_HOME/hive/config.yml`. +8. **Register the user service.** The collision-safe verified command path is + used in launchd or `systemd --user`. Non-interactive init registers but does + not start. Interactive init may start only after consent, and start failures + propagate. +9. **Run non-fatal preflight and print the next command.** Repeated invocations + converge on the same state. + +## Project prompt flow (ADR-023) + +When project config is absent and input is a TTY, the prompt asks for planning +agent, development agent, reviewer set, and per-stage limits. Agent and reviewer +answers accept stable names or 1-based indices. Name strings are safer for +automation because index ordering is a compatibility contract. + +On non-TTY input, project choices use the documented defaults and emit the +one-line summary. Provider configuration is different: it has no invented +default and must be supplied explicitly on its first run. ## Failure modes | Symptom | Cause | Fix | |---------|-------|-----| -| `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 | +| `not a git repository` | path is not a git repo | `git init` first | +| `target appears to be inside a worktree` | command ran in a feature worktree | run on the main checkout | +| `uncommitted changes` | first bootstrap sees a dirty tree | commit/stash, or deliberately pass `--force` | +| `first-run configuration requires ...` | a non-TTY run omitted provider fields | supply all three flags or environment variables | +| service registration/start error | launchd or the systemd user bus rejected the operation | repair the user service environment and rerun `hive init` | + +`Hive::AlreadyInitialized` and exit code 2 remain compatibility constants for +older callers; the current init path does not raise them for an existing branch. ## 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 bootstrap, repair, explicit provider + configuration, migration, registry, scaffold, and repeated init. +- `test/unit/user_config_test.rb` covers interactive/non-interactive provider + setup without secret persistence. +- `test/unit/state_migration_test.rb` covers verified legacy copy and active-XDG + registry adoption. ## Backlinks -- [[cli]] · [[commands/run]] -- [[modules/git_ops]] · [[modules/config]] · [[modules/agent_profile]] -- [[state-model]] · [[decisions]] (ADR-023) +- [[cli]] · [[commands/daemon]] +- [[modules/git_ops]] · [[modules/config]] · [[modules/paths]] +- [[distribution]] · [[state-model]] · [[decisions]] (ADR-023) diff --git a/wiki/commands/prune.md b/wiki/commands/prune.md index 531131be..e628132f 100644 --- a/wiki/commands/prune.md +++ b/wiki/commands/prune.md @@ -7,7 +7,10 @@ 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 +`$XDG_CONFIG_HOME/hive/config.yml` (or the explicit `HIVE_HOME` override) whose +project path is missing or whose row shape is invalid. Project `.hive-state` +directories are not touched. Bulk version of [[commands/forget]]. ## Usage diff --git a/wiki/decisions.md b/wiki/decisions.md index 9ec47ffb..9c8aa0d3 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -27,7 +27,10 @@ tags: [decisions, adr] **Status:** Active **Context:** A single `~/Dev/hive/state/` would route work by project name → path lookup. Per-project state lets each project's own `CLAUDE.md` / `.claude/` / hooks apply automatically (claude picks them up from `cwd`). -**Decision:** Each project owns `/.hive-state/` plus a registration entry in `~/Dev/hive/config.yml`. `~/Dev/hive/` is a thin control plane (CLI + global config + shared logs only). +**Decision:** Each project owns `/.hive-state/` plus a registration +entry in `$XDG_CONFIG_HOME/hive/config.yml`. Current per-user config, data, and +state use [[modules/paths]]; legacy `~/Dev/hive/` is retained only as a +non-destructive migration source. **Consequences:** Routing is free (project name = path). Per-project tooling works without magic. Failure in one project doesn't infect others. Cost: duplicate config knobs per project (acceptable; defaults cover most cases). ## ADR-003: Orphan branch `hive/state` checked out as a separate worktree diff --git a/wiki/distribution.md b/wiki/distribution.md new file mode 100644 index 00000000..aa3c095c --- /dev/null +++ b/wiki/distribution.md @@ -0,0 +1,101 @@ +--- +title: Distribution and installation +type: architecture +source: lib/hive/paths.rb, lib/hive/platform.rb, lib/hive/install_provenance.rb, install.sh, packaging/ +created: 2026-07-15 +updated: 2026-07-15 +tags: [architecture, install, packaging, xdg, release] +--- + +**TLDR**: Hive ships as Tebako-packaged, runtime-free binaries from immutable +semver GitHub Releases. Tier-1 channels are the fully qualified +`ivankuznetsov/hive/hive` Homebrew formula (macOS 14+ arm64), AUR `hive-bin` +(current Arch x86_64), and a verified Bash installer (Ubuntu 22.04+ x86_64). +All channels consume the same deterministic artifacts; update/uninstall require +positive ownership proof. Agent skills live in a separately published +`@ivankuznetsov/hive-skills` package. + +## Runtime boundary + +| Module | Role | +|--------|------| +| [[modules/paths]] `Hive::Paths` | XDG config/data/state/cache/runtime resolution | +| `Hive::Platform` | Tier-1/2/3 classification | +| `Hive::Assets` | Materialize package seeds under `$XDG_DATA_HOME/hive/versions/` | +| `Hive::DependencyCheck` | Hard / channel / optional tool diagnostics | +| `Hive::InstallProvenance` | Detect brew / AUR / Bash ownership | + +## Commands + +- `hive update` — channel-aware upgrade ([[commands]] surface) +- `hive uninstall` — stop service, remove binary via owner, optional hash-verified project cleanup +- `hive` / `hv` — identical CLI (`bin/hv` → `bin/hive`) + +## Release trust boundary + +- `scripts/build-release` requires Tebako for normal release builds and fails + closed when packaging fails. Its source-wrapper escape hatch exists only for + explicit tests. +- `SOURCE_DATE_EPOCH`, normalized GNU tar headers, gzip `-n`, sorted metadata, + and epoch-derived manifest timestamps make equivalent source inputs produce + identical bytes. +- `scripts/generate-release-manifest` requires the tag, `Hive::VERSION`, staged + `VERSION`, and packaged `--version` identity to agree. Every release includes + `install.sh`, and `SHA256SUMS` covers it along with all archives. +- Publication depends on runtime-closure tests of extracted artifacts plus clean + Ubuntu Bash, macOS Homebrew, and Arch package acceptance jobs. Release + promotion pushes the generated tap Formula and AUR `PKGBUILD`/`.SRCINFO`. + +## Bash installer safety + +- `--version` is strict semver and all derived roots are validated before use. +- Archive paths and member types are checked before extraction; links and + traversal paths are rejected. +- The staged payload must report the requested version before the current + payload changes. Same-version replacement retains a rollback copy until both + installed command links pass exact-version verification. +- The normal channel installs both `hive` and `hv`; `--command-name hv` preserves + an unrelated `hive`. The canonical receipt remains at + `$XDG_DATA_HOME/hive/install-receipt.yml` for custom prefixes. + +## Ownership and lifecycle + +- Bash receipts bind the invoked command path, versioned payload, and digest. + Replaced commands and stale receipts do not establish ownership. +- Homebrew detection resolves the stable symlink against + `brew --prefix ivankuznetsov/hive/hive`; delegation never uses the colliding + unqualified `hive` formula name. +- Bash update resolves a semver release and verifies the immutable tag's + installer checksum before execution. Update then probes the resulting command + version and requires service refresh/state preservation. +- Uninstall resolves unambiguous ownership before service or project mutation, + stops on service cleanup failure, and removes only receipt-revalidated Bash + links. Project cleanup is constrained to the shipped scaffold allowlist plus + recorded hashes. + +## Service adapters + +- `Hive::Service::Launchd` — macOS LaunchAgent `dev.hive.daemon` +- `Hive::Service::SystemdUser` — Linux user unit `hive.service` +- Registered by `hive init`; never autostarted non-interactively + +## Packaging tree + +``` +install.sh +INSTALL_PROMPT.md +packaging/homebrew/hive.rb.erb +packaging/aur/{PKGBUILD.erb,hive.install} +packaging/hive-skills/ # separate multi-agent skills package +scripts/{build-release,generate-release-manifest,verify-release} +scripts/render-package-metadata +.github/workflows/{release,release-smoke,tier1-e2e}.yml +``` + +## Backlinks + +- [[architecture]] +- [[commands/init]] +- [[commands/daemon]] +- [[modules/config]] +- [[modules/paths]] diff --git a/wiki/index.md b/wiki/index.md index 2b728a8f..374207a0 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -3,15 +3,15 @@ title: hive Wiki type: index source: wiki/**/*.md created: 2026-05-14 -updated: 2026-05-16 +updated: 2026-07-15 tags: [index, wiki] --- **TLDR**: Catalog of the LLM-maintained wiki for `hive`. -Page count: 60 -Updated: 2026-05-16 +Page count: 62 +Updated: 2026-07-15 Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eight-stage filesystem state machine (`1-inbox` → `2-brainstorm` → `3-plan` → `4-execute` → `5-open-pr` → `6-review` → `7-finalize` → `8-done`) where stage agents run via configurable AgentProfile CLIs (`claude` default, `codex`, `pi`) and `mv` between directories is the approval primitive. @@ -38,6 +38,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig - [[commands/tui]] — `wiki/commands/tui.md` - [[decisions]] — `wiki/decisions.md` - [[dependencies]] — `wiki/dependencies.md` +- [[distribution]] — `wiki/distribution.md` - [[e2e]] — `wiki/e2e.md` - [[gaps]] — `wiki/gaps.md` - [[index]] — `wiki/index.md` @@ -54,6 +55,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig - [[modules/lock]] — `wiki/modules/lock.md` - [[modules/markers]] — `wiki/modules/markers.md` - [[modules/metrics]] — `wiki/modules/metrics.md` +- [[modules/paths]] — `wiki/modules/paths.md` - [[modules/protected_files]] — `wiki/modules/protected_files.md` - [[modules/rebase]] — `wiki/modules/rebase.md` - [[modules/reviewers]] — `wiki/modules/reviewers.md` diff --git a/wiki/log.md b/wiki/log.md index 641bec70..0c464778 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1607,3 +1607,41 @@ 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-15T00:00:00Z] distribution and installation (U1–U9) + +**Action:** Implemented runtime-free distribution: XDG Paths, Platform tiers, +Assets materialization, DependencyCheck, project `.hive/` ownership scaffold, +legacy-state migration, user config with credential references only, +LaunchAgent/systemd service adapters, channel-aware `update`/`uninstall`, +release scripts + provenance gates, Homebrew/AUR templates, Bash installer + +install prompt, hive-skills package scaffolding, and install documentation. + +**Refreshed pages:** +- `wiki/distribution.md` — new page for packaging/install architecture +- `wiki/index.md` — catalog entry for distribution + +## [2026-07-15T12:00:00Z] distribution review hardening + +**Action:** Closed the stage-6 distribution and lifecycle review findings. The +release pipeline now produces deterministic Tebako artifacts and gates +publication on runtime closure and native tier-1 installs; Bash installation, +receipt ownership, update, uninstall, and rollback paths fail closed. Init is +repairable and adopts legacy registry data into explicit XDG configuration, +while daemon lifecycle uses truthful launchd/systemd-user control. Published +install guidance now uses executable, fully qualified channels and explicit +provider/model credential references. + +**Refreshed pages:** +- `wiki/distribution.md` — release identity, deterministic packaging, installer + validation/rollback, channel ownership, and publication gates. +- `wiki/cli.md` — init flags and repair semantics, update/uninstall, managed + daemon lifecycle, active XDG registry. +- `wiki/commands/init.md` — idempotent bootstrap/repair, first-run provider + configuration, legacy adoption, and service registration. +- `wiki/commands/daemon.md`, `wiki/operating.md` — launchd/systemd-user routing, + required nullable service status, truthful failures, and XDG paths. +- `wiki/modules/config.md` — combined XDG registry/provider config and legacy + merge behavior. +- `wiki/modules/paths.md` — new page resolving the former broken backlink. +- `wiki/index.md` — cataloged `modules/paths` and bumped the page count. diff --git a/wiki/modules/bot.md b/wiki/modules/bot.md index c7df393a..77d3241d 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 $XDG_STATE_HOME/hive/.bot.pid └─ 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..a35c601e 100644 --- a/wiki/modules/config.md +++ b/wiki/modules/config.md @@ -3,11 +3,18 @@ title: Hive::Config type: module source: lib/hive/config.rb created: 2026-04-25 -updated: 2026-05-14 -tags: [config, yaml, validation] +updated: 2026-07-15 +tags: [config, yaml, validation, xdg] --- -**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` (provider/model credential reference, +registered projects, and global daemon/bot settings) 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. ## Defaults (`Config::DEFAULTS`) @@ -68,8 +75,8 @@ tags: [config, yaml, validation] | Function | Returns / does | |----------|----------------| -| `hive_home` | `ENV["HIVE_HOME"] || ~/Dev/hive` | -| `global_config_path` | `/config.yml` | +| `hive_home` | explicit `ENV["HIVE_HOME"]`, otherwise `$XDG_STATE_HOME/hive` | +| `global_config_path` | `HIVE_HOME/config.yml` when overridden, otherwise `$XDG_CONFIG_HOME/hive/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. | | `registered_projects` | Reads global config; returns `[{name, path, hive_state_path}, …]` (paths `expand_path`-ed). | @@ -121,9 +128,25 @@ cfg.dig("review", "reviewers") cfg["worktree_root"] ``` +## XDG user configuration and legacy adoption + +`Hive::UserConfig#ensure_configured!` owns the first-run `provider`, `model`, +and `api_key_env` keys in the global config. It requires explicit values from +the init flags/environment or an interactive prompt, validates that +`api_key_env` is an environment-variable name, and never stores a secret value. + +`Hive::StateMigration` copies verified legacy material to +`$XDG_STATE_HOME/hive/legacy-import` and merges legacy +`~/Dev/hive/config.yml` data into the active global XDG config. Active values +win; missing registered-project rows are adopted; the source is retained. This +keeps legacy project enrollment and daemon consent visible after the XDG switch. + ## `HIVE_HOME` override -Tests use `with_tmp_global_config` (`test/test_helper.rb:30`) to point `HIVE_HOME` at a tmp dir, ensuring no test ever writes the real global config. +Tests use `with_tmp_global_config` (`test/test_helper.rb`) to point `HIVE_HOME` +and XDG config at temporary directories, ensuring no test writes real user +state. The helper restores every process-global environment variable in an +`ensure` block so randomized suite order cannot inherit deleted temp paths. ## Tests @@ -132,4 +155,4 @@ Tests use `with_tmp_global_config` (`test/test_helper.rb:30`) to point `HIVE_HOM ## Backlinks - [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/status]] -- [[modules/agent]] · [[state-model]] +- [[modules/agent]] · [[modules/paths]] · [[state-model]] · [[distribution]] diff --git a/wiki/modules/daemon.md b/wiki/modules/daemon.md index 81498482..0f1d20f4 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 $XDG_STATE_HOME/hive/.daemon.pid └─ 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/modules/paths.md b/wiki/modules/paths.md new file mode 100644 index 00000000..6346bde1 --- /dev/null +++ b/wiki/modules/paths.md @@ -0,0 +1,50 @@ +--- +title: Hive::Paths +type: module +source: lib/hive/paths.rb +created: 2026-07-15 +updated: 2026-07-15 +tags: [paths, xdg, install, state] +--- + +**TLDR**: `Hive::Paths` is the single resolver for per-user config, data, +state, cache, runtime, binary, version, and legacy-adoption locations. XDG +environment values must be absolute. `HIVE_HOME` remains an explicit combined +config/state override; legacy `~/Dev/hive` is an import source, never the +automatic live home. + +## Resolved locations + +| Method | Default | +|--------|---------| +| `config_home` | `$XDG_CONFIG_HOME/hive` or `~/.config/hive` | +| `data_home` | `$XDG_DATA_HOME/hive` or `~/.local/share/hive` | +| `state_home` | `$XDG_STATE_HOME/hive` or `~/.local/state/hive` | +| `cache_home` | `$XDG_CACHE_HOME/hive` or `~/.cache/hive` | +| `runtime_dir` | `$XDG_RUNTIME_DIR/hive` or a private UID-scoped temp root | +| `bin_dir` | `$XDG_BIN_HOME` or `~/.local/bin` | +| `versions_dir` | `/versions` | +| `user_config_path` / `global_registry_path` | `/config.yml` | +| `hive_home` | explicit `HIVE_HOME`, otherwise `` | + +When `HIVE_HOME` is explicitly set, registry/config access remains isolated +below that directory for compatibility and tests. It does not cause the normal +XDG layout to collapse in production. + +## Safety properties + +- Relative XDG paths raise `Hive::ConfigError` before a write can land relative + to the current project. +- Runtime and created directories are mode `0700`; private config writes are + atomic, fsynced, renamed, and mode `0600`. +- Version directories live below the immutable data root. Bash install receipts + stay at `$XDG_DATA_HOME/hive/install-receipt.yml` even when the payload uses a + custom prefix, so lifecycle commands can discover ownership. +- `legacy_global_home` (`~/Dev/hive`) and `legacy_state_home` (`~/.hive-state`) + are only inputs to `Hive::StateMigration`. Verified copies go to XDG state, + while the legacy registry is merged into the active XDG config and retained. + +## Backlinks + +- [[distribution]] · [[modules/config]] · [[commands/init]] +- [[commands/daemon]] · [[architecture]] diff --git a/wiki/operating.md b/wiki/operating.md index bf12c796..d4fba388 100644 --- a/wiki/operating.md +++ b/wiki/operating.md @@ -3,14 +3,14 @@ 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-15 tags: [operating, daemon, bot, systemd, launchd, install] --- **TLDR**: Day-2 guide for running the hive daemon and Telegram bot. Covers per-project daemon enrollment, bot token/allowlist setup, -autostart on macOS (launchd) and Linux (systemd), dry-run shakedowns, -log inspection, and how to disable automation mid-flight. +registered service lifecycle on macOS (launchd) and Linux (systemd), log +inspection, and how to disable automation mid-flight. ## Prerequisites @@ -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 @@ -57,55 +58,48 @@ toggle only flips `enabled`. For new projects, `hive init` asks at the TTY prompt and defaults to Y — no separate enable step. -## First run: the mandatory `--dry-run` shakedown +## First registered-service run -The daemon's worst-case in-flight cost ceiling is -`max_concurrent_runs × per-task budget cap` from ADR-023, which works -out to ~$4425 at default caps. Before letting it spawn real children, -run it in dry-run for ~24 hours: +`hive init` writes the platform service definition but never starts it in a +non-interactive session. Confirm the enrolled projects, then explicitly start +the registered service: ```bash -hive daemon start --dry-run --detach -hive daemon status # verify running -hive daemon tail # ctrl-C to leave; daemon keeps running +hive daemon status +hive daemon start +hive daemon status --json +hive daemon tail # Ctrl-C leaves the service 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/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/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 -``` - -Once the would-be dispatches look right, swap to live mode: - -```bash -hive daemon stop -hive daemon start --detach + "$XDG_STATE_HOME/hive/logs/daemon.log" | sort -u ``` ## Autostart ### Linux (systemd-user) -A sample unit ships at `examples/systemd/hive-daemon.service`. Install: +`hive init` renders `$XDG_CONFIG_HOME/systemd/user/hive.service` with the stable, +verified `hive` or `hv` command and explicit `HIVE_HOME`/XDG environment. Start +and inspect it through Hive: ```bash -mkdir -p ~/.config/systemd/user -cp examples/systemd/hive-daemon.service ~/.config/systemd/user/ -systemctl --user daemon-reload -systemctl --user enable --now hive-daemon -journalctl --user -u hive-daemon -f +hive daemon start +hive daemon status --json +journalctl --user -u hive -f ``` If you log out and want the daemon to keep running: @@ -114,10 +108,10 @@ If you log out and want the daemon to keep running: sudo loginctl enable-linger $USER ``` -The unit declares `Type=simple` and runs `hive daemon start` in the -foreground — systemd is the supervisor. `Restart=on-failure` brings -the daemon back after a crash; the daemon's own SIGTERM handler does -the graceful drain (`daemon.shutdown_grace_sec`, default 600 s). +The unit runs `hive daemon run` in the foreground so systemd remains the +supervisor. A missing user bus or failed enable/start operation is an error; +repair the user session and rerun `hive init` rather than assuming registration +succeeded. The shipped unit hardcodes `TimeoutStopSec=900` (15 min — drain budget plus headroom). If you raise `daemon.shutdown_grace_sec` above 900, @@ -126,51 +120,23 @@ plus headroom). If you raise `daemon.shutdown_grace_sec` above 900, systemd will SIGKILL still-running stage children mid-`hive run`, losing in-flight work. -If `hive` lives behind a version manager (rbenv / asdf / mise), edit -the `ExecStart=` line to use the shim's absolute path — systemd-user -doesn't load your shell's rc files. - ### macOS (launchd) -A sample plist ships at `examples/launchd/hive-daemon.plist`. -Edit the absolute paths first (replace `/Users/YOU/...` with your -real paths — `which hive` shows the binary), then: +`hive init` renders `~/Library/LaunchAgents/dev.hive.daemon.plist` from the +stable verified command. Control the registered job through Hive: ```bash -mkdir -p ~/Library/LaunchAgents -cp examples/launchd/hive-daemon.plist ~/Library/LaunchAgents/ -launchctl load ~/Library/LaunchAgents/hive-daemon.plist -``` - -Stop / restart: - -```bash -launchctl unload ~/Library/LaunchAgents/hive-daemon.plist -launchctl load ~/Library/LaunchAgents/hive-daemon.plist +hive daemon start +hive daemon status --json +hive daemon stop ``` -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 -preferred for parsing — the launchd capture is mostly empty. - -`KeepAlive` with `SuccessfulExit: false` means launchd respawns on -crash but not after a clean `hive daemon stop`. `ThrottleInterval: 30` -is the floor between respawn attempts. - -The plist's `ProgramArguments` wraps the invocation in a tiny -`/bin/sh -c '[ -x "$0" ] || exit 0; exec "$0" "$@"'` precheck — -launchd has no native equivalent of systemd's `StartLimitBurst`, so -without this wrapper a wrong binary path would respawn every 30 s -forever (filling `hive-daemon.err.log` with `command not found`). The -wrapper turns "binary missing or not executable" into a clean exit 0, -which `KeepAlive { SuccessfulExit: false }` then respects (no respawn). -A real daemon crash still exits non-zero through `exec` and respawns -normally. If you customise `ProgramArguments`, keep the precheck. +Launchd failures propagate to the CLI. The daemon's structured log under +`$XDG_STATE_HOME/hive/logs/daemon.log` remains the preferred diagnostic source. ## 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 @@ -186,12 +152,12 @@ export HIVE_TELEGRAM_BOT_TOKEN=123456:token-from-botfather hive bot start --dry-run ``` -`--dry-run` is useful for first shakedown: inbound command parsing and +`--dry-run` is useful for the bot's 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/hive/logs/bot.log" | sort | uniq -c ``` Unauthorized chat IDs are logged once and receive no reply. Missing @@ -229,14 +195,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 +237,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: @@ -293,7 +259,7 @@ If `daemon.log` shows unexpected dispatch volume: hive daemon stop # immediate halt hive daemon disable --all # belt-and-suspenders # investigate (jq filters above) -hive daemon start --dry-run --detach # re-shakedown +hive daemon start # restart registered service ``` The per-task `.lock` (ADR-007) is the last-resort safety net; the @@ -308,7 +274,7 @@ 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_STATE_HOME/hive/.daemon.pid`. 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. @@ -333,4 +299,4 @@ safest path. - [[commands/daemon]] · [[modules/daemon]] - [[commands/bot]] · [[modules/bot]] - [[decisions]] (ADR-024, ADR-026) · [[active-areas]] -- [[architecture]] · [[cli]] +- [[architecture]] · [[cli]] · [[modules/paths]] · [[distribution]] diff --git a/wiki/state-model.md b/wiki/state-model.md index 29a9e1a2..6efbf8f0 100644 --- a/wiki/state-model.md +++ b/wiki/state-model.md @@ -7,7 +7,12 @@ 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 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 `$XDG_CONFIG_HOME/hive/config.yml`. The "data model" is the +directory layout, marker grammar, and YAML schemas described below. ## Stage directory layout @@ -142,7 +147,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 +164,31 @@ 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/.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`. - -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. - -`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. +Managed by `Hive::Config.register_project`; 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` is an explicit +combined config/state override; the default is the XDG split in [[modules/paths]]. + +Loader tolerance (`Config.registered_projects` / `load_global_config`): a +non-Hash row, a row missing `name`, or a row whose `path` is not a String is +skipped rather than bricking `status`/`forget`/`prune`/TUI. Malformed YAML and +config access errors become `ConfigError` (exit 78). `prune` removes invalid or +missing-path rows. All writers use `write_global_config!`; registry paths exposed +through JSON are expanded to the schemas' absolute-path contract. See +[[commands/forget]] · [[commands/prune]] · [[modules/config]]. + +`bot:` is a global operator-surface block, not a per-project enrollment knob. +`Config.load_global_bot(require_runtime: true)` merges it over XDG-state-derived +defaults, validates integer chat IDs, poll bounds, and paths, then requires both +a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN`. Runtime PID, JSON log, and +last-seen files live under `$XDG_STATE_HOME/hive` by default. ### Per-project: `/.hive-state/config.yml` diff --git a/wiki/testing.md b/wiki/testing.md index 28096a6d..3253c9cb 100644 --- a/wiki/testing.md +++ b/wiki/testing.md @@ -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` — overrides `HIVE_HOME`, XDG config, and first-run + provider-reference environment values in a tmp dir; all original process + environment is restored in `ensure` so randomized order never inherits a + deleted test home. - `run!(*cmd)` — shells out and raises on non-zero exit (used in setup helpers; not for testing the CLI itself). ## Fixtures