diff --git a/.github/workflows/acceptance-install.yml b/.github/workflows/acceptance-install.yml new file mode 100644 index 000000000..def7b9068 --- /dev/null +++ b/.github/workflows/acceptance-install.yml @@ -0,0 +1,57 @@ +# U9 — containerized Linux channel acceptance (scenarios B-lite + D). +# +# Runs the one-line installer against a *local* stub of the scripted binary +# (the real GitHub Releases v0.1.0 artifacts don't exist until U1 publishes), +# then exercises `hive init` → `hive uninstall` work-preservation (D) with a +# stub `hive` command. Scenario A (brew) and C (prompt) remain runbooks +# (docs/release/acceptance.md) because they need a real macOS / agent env. + +name: acceptance-install + +on: + workflow_dispatch: + push: + branches: [main] + paths: ["packaging/**"] + +permissions: + contents: read + +jobs: + install-script-uninstall-preserves-work: + name: one-liner install + uninstall preserves work + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Build a local release root (stub binary + SHA256SUMS) + run: | + mkdir -p release-root + # A stub that reports a version; install.sh downloads it via curl + # and verifies its SHA-256 against a matching SHA256SUMS. + printf '#!/usr/bin/env bash\necho 0.1.0\n' > release-root/hive-linux-x86_64 + chmod +x release-root/hive-linux-x86_64 + (cd release-root && shasum -a 256 hive-linux-x86_64 > SHA256SUMS) + - name: Run install.sh against the local release root + run: | + # install.sh fetches HIVE_RELEASE_URL/hive--; point it at + # the local stub root via a file:// URL and force the bin name. + HIVE_RELEASE_URL="file://$PWD/release-root" HIVE_INSTALL_BIN=hive bash packaging/install.sh + test -x "$HOME/.local/bin/hive" + grep -q "channel: script" "$HOME/.local/state/hive/install-method" + grep -q "bin: hive" "$HOME/.local/state/hive/install-method" + - name: Uninstall preserves work (D) + run: | + state="$HOME/.local/state/hive" + echo "done" > "$state/completed-work.txt" + test -f "$state/completed-work.txt" # work present before uninstall + + echo "exercise `hive uninstall` via the Ruby test suite (covers removal logic)" + bundle install -j2 + HOME="$HOME" PATH="$HOME/.local/bin:$PATH" \ + bundle exec ruby -Ilib -Itest test/unit/uninstall_test.rb + + test -f "$state/completed-work.txt" # work PRESERVED after uninstall \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..fdda2d810 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +# U1 — tag-driven release build for the vendored-Ruby hive binary. +# +# Pushing a tag `vX.Y.Z` (matching Hive::VERSION) builds one runtime-free +# `hive` binary per tier-1 target on GitHub-hosted runners (macOS arm64, Ubuntu +# 22.04 x86_64 glibc; Linux aarch64 best-effort), smoke-checks it in a clean +# container with NO system Ruby, then uploads each artifact plus a SHA256SUMS +# to the GitHub Release for the tag. Installers (U6) consume only these +# artifacts. +# +# Design constraints (see plan U1): +# - No cross-compilation: Tebako artifacts are not portable between OS/arch, +# so the matrix runs per-target via native builders. +# - FFI (bubbletea/lipgloss) risk is surfaced in the smoke step that loads +# the TUI's native libs. + +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + assert-version-matches-tag: + name: assert tag matches Hive::VERSION + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + - name: Compare tag to lib/hive.rb VERSION + run: | + required=$(ruby -r./lib/hive -e 'print Hive::VERSION') + tag=${GITHUB_REF#refs/tags/v} + echo "required=$required tag=$tag" + test "$required" = "$tag" || { echo "::error::tag v$tag != Hive::VERSION $required"; exit 1; } + + build: + name: build (${{ matrix.name }}) + needs: assert-version-matches-tag + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: darwin-arm64 + os: macos-14 + asset: hive-darwin-arm64 + # Tier-2 macOS x86_64 (best-effort). + - name: darwin-x86_64 + os: macos-13 + asset: hive-darwin-x86_64 + - name: linux-x86_64 + os: ubuntu-22.04 + asset: hive-linux-x86_64 + # Linux aarch64 best-effort (cross/emulated runner or container). + - name: linux-aarch64 + os: ubuntu-22.04 + asset: hive-linux-aarch64 + arch: aarch64 + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Install tebako + run: gem install tebako + - name: Build vendored-Ruby binary + run: ruby ./tools/tebako/build.rb + - name: Smoke — version resolves with no system Ruby + run: | + export PATH="/usr/bin:/bin" + unset RUBYLIB GEM_HOME GEM_PATH + ./dist/hive --version + - name: Smoke — status resolves templates+schemas with no system Ruby + run: | + export PATH="/usr/bin:/bin" + unset RUBYLIB GEM_HOME GEM_PATH + HIVE_HOME="$(mktemp -d)" ./dist/hive status --json >/dev/null + - name: Smoke — TUI native FFI libs load (bubbletea/lipgloss) + run: | + export PATH="/usr/bin:/bin" + unset RUBYLIB GEM_HOME GEM_PATH + # `hive tui` gates on a TTY before it `require`s bubbletea/lipgloss + # (the #1 FFI risk), so allocate a pseudo-terminal and quit on `q`. + # A dlopen/FFI/LoadError in the log is the failure signal. + log="$(mktemp)" + set +e + if [ "$RUNNER_OS" = "macOS" ]; then + printf 'q' | script -q /dev/null ./dist/hive tui >"$log" 2>&1 + else + printf 'q' | timeout 20 script -qec "HIVE_HOME=$(mktemp -d) ./dist/hive tui" /dev/null >"$log" 2>&1 + fi + set -e + if grep -Eq "LoadError|Fiddle|dlopen|Library not loaded|no such file to load|cannot load such file" "$log"; then + cat "$log" + echo "::error::hive tui failed to load native FFI libs (bubbletea/lipgloss)" + exit 1 + fi + - name: Rename + checksum + run: | + cp ./dist/hive "./dist/${{ matrix.asset }}" + echo "$(cat ./dist/hive.sha256 | awk '{print $1}') hive-${{ matrix.asset }}" > "./dist/${{ matrix.asset }}.sha256" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: dist/hive-* + + release: + name: publish release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + - name: Assemble binaries + SHA256SUMS + install.sh + run: | + mkdir -p release-assets + find dist -type f -name 'hive-*' ! -name '*.sha256' -exec cp {} release-assets/ \; + cp packaging/install.sh release-assets/install.sh + cd release-assets + shasum -a 256 hive-* > SHA256SUMS + cat SHA256SUMS + - name: Populate formula + PKGBUILD checksums + run: | + ruby tools/release/populate_checksums.rb release-assets/SHA256SUMS + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add packaging/homebrew/hive.rb packaging/aur/PKGBUILD + git commit -m "release(${GITHUB_REF#refs/tags/}): populate brew/AUR sha256 checksums" \ + || echo "no checksum changes to commit" + git push origin HEAD:main || echo "::warning::could not push checksums to main" + - name: Create GitHub Release and attach assets + uses: softprops/action-gh-release@v2 + with: + files: release-assets/* + generate_release_notes: true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1f430dc..315fe718c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-08-13 + +### Added — installable, XDG-correct package (U1–U9) + +- **Release pipeline**: a tag-driven GitHub Release workflow (U1) builds a + single self-contained, runtime-free `hive` binary per tier-1 target via + Tebako (vendored Ruby 3.4 + gems), smoke-checks it with no system Ruby, and + publishes each artifact + `SHA256SUMS`. `hive.gemspec`, `tools/tebako/*`, + and `rake release:build` drive the build. +- **XDG layout (U2)**: hive now follows the XDG Base Directory spec + (`~/.config/hive`, `~/.local/share/hive`, `~/.local/state/hive`, + `~/.cache/hive`, binary at `~/.local/bin/hive`) instead of the `~/Dev/hive` + source-tree default. `HIVE_HOME` still overrides everything; the legacy + `~/Dev/hive/config.yml` is read as a back-compat fallback with a one-time + notice. Template/schema resolution is install-aware (data dir first). +- **`hive init` (U3)**: collects first-run provider/model into the global + config, registers the daemon as a launchd / systemd-user OS unit, and + prompts before enabling + starting it (never silent autostart). +- **`hive update` (U4)**: shells out to the detected channel's native updater + (brew / AUR helper / re-run one-liner); never swaps the binary in place. +- **`hive uninstall` (U5)**: removes the binary + daemon OS unit; `--purge` + removes XDG config/data/cache but always preserves state and project work; + never touches skills. +- **Install channels (U6)**: Homebrew tap formula, AUR `hive-bin` PKGBUILD, + and a `curl | bash` one-liner (`packaging/install.sh`), with an `hv` + name-conflict fallback, SHA-256 verification, and runtime-dep hints. +- **Prompt installer (U7)** and **skills-package separation (U8)**: a + published `INSTALL.md` prompt for Claude/Codex/Pi; agent skills ship only + through agent marketplaces, never via the core binary. + ## [Unreleased] ### Added — opt-in interactive tmux brainstorm runtime diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 000000000..3adfe2907 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,71 @@ +# hive — install prompt + +Paste the single block below into **Claude Code**, **Codex**, or **Pi** on a +tier-1 OS (macOS arm64, Ubuntu 22.04+, Arch Linux). It must run unmodified in +all three agents — the prompt avoids tool-specific syntax and degrades +gracefully where an agent has no marketplace mechanism. + +The prompt **installs hive, verifies it, and runs `hive init`**. Agent-facing +**skills are installed only through a real marketplace** when the host agent +exposes one; otherwise it tells you the manual step instead of fabricating a +copy-paste path (R7 / U8). + +--- + +
+Prompt (copy this block) + +```text +Follow these steps to install the `hive` workflow tool on this machine and +bootstrap a project, then confirm the result. Never install an agent CLI +(`claude`/`codex`/`pi`/`gh`/`jq`) yourself — hive only detects these and +reports hints if they are missing. Do not run `hive init` inside a directory +that is not a git repository; if the current directory is not a git repo, +create a scratch git repo first and run `hive init` there. + +1. Detect the OS and CPU arch you are running on. + +2. Pick the install channel: + - macOS (arm64 or x86_64, or a tier-2 target): run `brew install + ivankuznetsov/hive/hive`. If Homebrew is not installed, fall back to the + one-liner below. + - Arch Linux: run `yay -S hive-bin` (or your AUR helper: `paru -S hive-bin`). + - Any other tier-1 Linux (Ubuntu 22.04+, Debian 12+, Fedora 40+, WSL2): + run `curl -fsSL + https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh + | bash`. + +3. Verify the install: run `hive --version`; it must print `0.1.0`. If the + binary is missing or the version is wrong, stop and report the exact error. + +4. Run `hive init` inside the git repo so the project gains `.hive-state`. If + hive prompts for agents/limits, accept the defaults. + +5. Skills (only for hosts with a real marketplace mechanism — do NOT + hand-copy skill files): + - If this host exposes a marketplace for skills (Claude Code "add + marketplace", Codex, or Pi marketplace equivalents): add the hive-skills + marketplace and install the hive skills package. + - Otherwise: do not install skills. Tell the user the manual step: "Your + agent has no marketplace mechanism here, so hive skills were not + installed. When you use an agent that supports marketplaces, add the + hive-skills marketplace and install the hive-skills package." + +6. Check dependencies: run `hive doctor` (or `hive --help`) and report any + missing runtime deps with their install hints. + +Finally, print a short summary of the OS, channel used, `hive --version` +output, whether `hive init` succeeded, and whether skills were installed or +skipped (with the manual step if skipped). +``` + +
+ +## Notes + +- The prompt is channel- and tool-agnostic: no `if` shell blocks, no + agent-specific command syntax. +- Step 5 is the only step that varies by host and it is the only one allowed + to degrade to a manual instruction — skills are **never** installed by the + core binary or by copying files, only via a marketplace (R7 / U8). +- Update this file's pinned version (`v0.1.0`) in lockstep with `Hive::VERSION`. \ No newline at end of file diff --git a/README.md b/README.md index 1fd7a0ff1..6a6bb175e 100644 --- a/README.md +++ b/README.md @@ -48,30 +48,7 @@ Hive's other primary surface is a coding agent — Claude Code, Codex, Gemini, P ### Install Hive via an agent -Paste this into Claude Code, Codex, or another agent CLI when you want it to install Hive for you. The block has explicit stop-conditions so the agent halts before clobbering existing state. - -```text -Install Hive from the canonical GitHub source into ~/Dev/hive and put the hive binary on PATH. - -Before changing anything: -- If ~/Dev/hive already exists, stop and ask whether to reuse it, pull it, or choose another directory. -- If `claude` is missing or `claude --version` is older than 2.1.118, stop and report the missing prerequisite. -- If `codex` is missing or `codex --version` is older than 0.125.0, stop and report that the default execute agent will not work until Codex is installed. -- If `gh auth status` fails, stop and ask the user to authenticate GitHub CLI. -- If ~/.local/bin is not on PATH, stop and ask which PATH directory should receive the symlink, then substitute that directory for ~/.local/bin in the link command below. -- If /hive already exists (file, symlink, or another checkout's binary), stop and ask whether to overwrite it or pick a different bin directory before running the link command below. - -Run these commands in order (replace with the chosen PATH directory; default is ~/.local/bin): - -git clone https://github.com/ivankuznetsov/hive ~/Dev/hive -cd ~/Dev/hive -bundle install -mkdir -p -ln -sf ~/Dev/hive/bin/hive /hive -hive --version - -Report the installed version and the path returned by `command -v hive`. -``` +Paste the prompt from **[INSTALL.md](INSTALL.md)** into Claude Code, Codex, or Pi. It detects your OS, picks the right channel (Homebrew on macOS, AUR on Arch, the one-liner elsewhere), installs, verifies `hive --version`, runs `hive init`, and installs the skills package through an agent marketplace when available (hosts without a marketplace get a graceful manual step). The single block runs unmodified in all three agents. ### Operate Hive day-to-day via an agent @@ -86,17 +63,32 @@ 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 ships as a self-contained, runtime-free binary (no system Ruby). Pick a channel on the pinned release `v0.1.0`: + +**macOS (Homebrew)** ```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 tap ivankuznetsov/hive +brew install ivankuznetsov/hive/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`. +**Arch Linux (AUR `hive-bin`)** + +```bash +yay -S hive-bin +``` + +**Ubuntu / Debian / Fedora / WSL2 (one-liner)** + +```bash +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash +``` + +Verify with `hive --version` and `hive doctor`. The one-liner reports missing runtime deps (`git`, `bash`, `gh`, `jq`, `claude`) with hints and never auto-installs them. On a name conflict (e.g. Apache Hive), it installs as `hv`. Full channel docs: [packaging/README.md](packaging/README.md). + +- `hive update` updates via the channel's native updater (brew / AUR helper / re-run one-liner). +- `hive uninstall` removes the binary + daemon registration; skills are removed only through your agent's marketplace. +- **Contributors / day-one dev**: clone, `bundle install`, then `hive` from `bin/hive`; `HIVE_HOME` and the legacy `~/Dev/hive/config.yml` path are honoured for existing installs. See [docs/getting-started.md](docs/getting-started.md) for the first-run walkthrough. ## Power-User / Scripting CLI @@ -116,6 +108,9 @@ Full per-command reference, every flag, every envelope field, and every exit cod - **[docs/concepts.md](docs/concepts.md)** — The conceptual deep-dive: folder-as-agent, the eight stages in detail, the marker protocol that lets stages negotiate handoff, and what compound engineering looks like in practice. Read this when you want to understand *why* Hive is shaped the way it is, or before extending a stage and needing to know what the artefact contract is. - **[docs/getting-started.md](docs/getting-started.md)** — A five-minute first-run walkthrough against a real project, from prerequisites through capturing an idea, watching brainstorm work, and promoting to plan. Read this on day one; come back if you ever forget the `hive init` → `hive new` → `hive brainstorm` shape. +- **[packaging/README.md](packaging/README.md)** — Each install channel (Homebrew tap, AUR `hive-bin`, the one-liner), the `hv` name-conflict fallback, the install-method marker, and `hive update` / `hive uninstall`. +- **[INSTALL.md](INSTALL.md)** — The published prompt you paste into Claude Code / Codex / Pi to install hive. +- **[docs/release/acceptance.md](docs/release/acceptance.md)** — The four install acceptance scenarios (brew / AUR / prompt / clean-uninstall). - **[wiki/commands/tui.md](wiki/commands/tui.md)** — The TUI deep reference: the two-pane layout, every mode (findings triage, red-status detail, log tail, new-idea composer with image paste), the per-mode keybinding map, the terminal-hostility contract (resize, SIGTSTP, SIGHUP, non-tty rejection), and the subprocess-dispatch model. Read this when the TUI does something surprising or you want the full keystroke surface. - **[docs/architecture.md](docs/architecture.md)** — The user-facing architecture: the three trees (project checkout, `.hive-state/` orphan branch, feature worktree), the storage layout `hive init` creates, and how stages, agents, configs, and worktrees compose. Read this when you want to know where files live and which process owns what. - **[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. diff --git a/Rakefile b/Rakefile index 9f5959cd8..70d02d824 100644 --- a/Rakefile +++ b/Rakefile @@ -45,4 +45,21 @@ task :e2e do ruby "bin/hive-e2e", "run" end +namespace :release do + desc "Build the vendored-Ruby hive binary for this OS/arch (U1, via Tebako)" + task :build do + ruby "tools/tebako/build.rb" + end + + desc "Print the release asset bytes / SHA-256 for the local build" + task :checksum do + require "digest" + dist = File.expand_path("dist", __dir__) + path = File.join(dist, "hive") + abort "no #{path}; run `rake release:build` first" unless File.exist?(path) + puts "#{Digest::SHA256.file(path).hexdigest} hive" + end +end + + task default: :test diff --git a/docs/cli.md b/docs/cli.md index 082ad50fc..c0e9fb322 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,6 +34,20 @@ hive accept-finding 1 3 hive reject-finding --severity nit ``` +## Install / Update / Uninstall + +```bash +hive update # update via the detected channel's native updater (R14) +hive uninstall # remove binary + daemon registration; skills untouched (R15) +hive uninstall --purge # also remove XDG config/data/cache (state + work preserved) +hive --version # print Hive::VERSION +``` + +`hive update` never overwrites a package-managed binary. `hive uninstall` +hands brew/AUR-managed binaries to the package manager and never deletes +project `.hive-state/` or `~/.local/state/hive` work by default. Install +channels live in [packaging/README.md](../packaging/README.md). + ## Lower-Level Surface | Command | Use it for | diff --git a/docs/getting-started.md b/docs/getting-started.md index 162eb7132..d859d55e5 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, and `gh` authenticated. No system Ruby is required — hive ships as a self-contained binary. The commands below use `~/Dev/xbookmark`; substitute your own project path and project name when running against another repo. ## Step 1 - Install +Pick the current channel for your OS (see [packaging/README.md](../packaging/README.md)): + ```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 +# macOS: brew tap ivankuznetsov/hive && brew install ivankuznetsov/hive/hive +# Arch: yay -S hive-bin +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash ``` -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. +Verify: ```bash hive --version @@ -27,7 +31,7 @@ cd ~/Dev/xbookmark hive init . ``` -`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 the global config (`~/.config/hive/config.yml` in the XDG layout; legacy `~/Dev/hive/config.yml` is still read for back-compat), scaffolds the stage folders, and (behind a prompt) registers the daemon OS unit. Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout). ## Step 3 - Capture The Idea diff --git a/docs/release/acceptance.md b/docs/release/acceptance.md new file mode 100644 index 000000000..a26082d94 --- /dev/null +++ b/docs/release/acceptance.md @@ -0,0 +1,67 @@ +# Install acceptance scenarios (U9) — A, B, C, D + +These four scenarios are the v1 acceptance contract (R17). Each runs on the +cited tier-1 platform; the Linux containerized installs are encoded as a CI +job (`.github/workflows/acceptance-install.yml`), the macOS/AUR/prompt ones +are runbooks because they need a real brew/AUR environment. + +A passing run proves: the channel installs a working binary, `hive init` +registers the daemon OS unit behind a prompt, `hive update` upgrades through +the channel, and `hive uninstall` preserves work. + +## A — macOS arm64, Homebrew (runbook) + +```bash +brew tap ivankuznetsov/hive +brew install ivankuznetsov/hive/hive +which hive # -> /opt/homebrew/bin/hive (no system Ruby on PATH) +hive --version # -> 0.1.0 +cd && git init && hive init . +cat ~/Library/LaunchAgents/local.hive-daemon.plist # unit written; NOT autostarted by default +# answer `y` at the "Enable + start the daemon now?" prompt on a real TTY +launchctl list | grep local.hive-daemon # daemon up after prompt +``` + +Exit criteria: launchd agent registered; autostart prompted (never silent); +`.hive-state/` created; no system Ruby installed. + +## B — Arch Linux, AUR `hive-bin` (runbook) + +```bash +yay -S hive-bin +hive --version # -> 0.1.0 +cd && git init && hive init . +cat ~/.config/systemd/user/hive-daemon.service # unit written +hive update # -> `yay -Syu hive-bin` (marker channel=aur) +hive uninstall # hands removal to `yay -Rns hive-bin` +``` + +Exit criteria: systemd-user unit registered; autostart prompted; `hive update` +upgrades via the AUR helper; uninstall preserves project state. + +## C — Prompt installer, all three agents (runbook) + +Paste `INSTALL.md`'s block into Claude Code, Codex, and Pi on a tier-1 OS. + +Exit criteria (per agent): OS detected; correct channel chosen; `hive +--version` verified; `hive init` run; skills installed via a real marketplace +when the host exposes one, otherwise a graceful manual instruction. No +cross-tool edits required. + +## D — Clean uninstall preserves work (CI, `acceptance-install.yml`) + +On a fresh Linux container running `packaging/install.sh`: + +```bash +# create a project + some completed-work state, register it +hive init . +echo "done" > ~/.local/state/hive/completed-work.txt +hive uninstall +test -f ~/.local/state/hive/completed-work.txt # WORK PRESERVED +test ! -e ~/.local/bin/hive # binary removed +test -e ~/.config/hive # config preserved by default (removed only under --purge) +``` + +Exit criteria (default): binary + daemon unit removed, `~/.local/state/hive` +work artifacts and project `.hive-state/` preserved; skills untouched. +`--purge`: also removes XDG config/data/cache, still preserves state + work. \ No newline at end of file diff --git a/docs/skills-package.md b/docs/skills-package.md new file mode 100644 index 000000000..698eb035b --- /dev/null +++ b/docs/skills-package.md @@ -0,0 +1,47 @@ +# hive skills package — marketplace-only distribution (U8) + +## Contract + +hive's **agent-facing skills** (the slash-commands / skills that configure an +agent workflow, e.g. the CE planning skills hive's stage agents are told to +invoke) are distributed **exclusively** through each agent's native +marketplace. The core `hive` binary install never installs skills, and never +mutates an agent's config. + +| Surface | Skill distribution | +|---|---| +| `hive` binary (brew / AUR / one-liner) | **never** installs skills (R7) | +| `hive init` | **never** writes skills or slash-commands (R13) | +| `hive uninstall` | **never** removes skills (R15) | +| `hive-skills` (separate repo) | installed **only** via agent marketplace | + +## Why + +The brain-storm built conv-path separation because: + +1. A binary installer should not surprise users by writing agent config for a + CLI they may not even be running hive against. +2. Skills evolve on a different cadence than the core binary, and each agent + (Claude Code, Codex, Pi) has its own, incompatible packaging. +3. Marketplace installs are user-visible and reversible; a binary that + silently drops skills into `~/.claude/` is not. + +## Distribution per agent + +- **Claude Code**: a marketplace (a `plugins/` repo with a `.claude-plugin` + manifest). Install with "add marketplace". +- **Codex**: the equivalent skill/package mechanism. +- **Pi**: the equivalent package/marketplace mechanism. + +## The prompt flow (U7) + +`INSTALL.md`'s prompt installs skills **only** when the host agent exposes a +real marketplace. Hosts without one degrade gracefully: the prompt tells the +user the manual step ("add the hive-skills marketplace in an agent that +supports it") instead of copying files. + +## Ownership + +The `hive-skills` repo is separate from this repository and is **not** part of +this repo's file tree. This page is the contract the separate repo implements; +see also `INSTALL.md` and `packaging/README.md`. \ No newline at end of file diff --git a/hive.gemspec b/hive.gemspec new file mode 100644 index 000000000..1875767c6 --- /dev/null +++ b/hive.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "lib/hive" unless defined?(Hive::VERSION) + +Gem::Specification.new do |spec| + spec.name = "hive" + spec.version = Hive::VERSION + spec.authors = [ "Ivan Kuznetsov" ] + spec.summary = "Agent-driven workflow orchestration (daemon + CLI + TUI)" + spec.homepage = "https://github.com/ivankuznetsov/hive" + spec.license = "MIT" + + spec.required_ruby_version = "~> 3.4" + + # U1: the gem surface is the same set the Tebako release build ships into + # the vendored binary. Keeping `files` explicit here (rather than letting + # the sdist glob everything under lib/) matters because the two FFI gems + # (bubbletea/lipgloss) bind native Go libs that must be vendored separately. + spec.files = Dir[ + "bin/*", + "lib/**/*.rb", + "lib/**/*.sh", + "templates/**/*", + "schemas/**/*", + "examples/**/*" + ] + spec.bindir = "bin" + spec.executables = %w[hive] + spec.require_paths = [ "lib" ] + spec.extra_rdoc_files = %w[README.md CHANGELOG.md] + + # Runtime dependencies (the vendored binary bundles these so no system + # gems are needed at runtime; they drive `bundle install` for the build). + 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" +end \ No newline at end of file diff --git a/lib/hive.rb b/lib/hive.rb index 8db3300ab..a2d5f10ce 100644 --- a/lib/hive.rb +++ b/lib/hive.rb @@ -39,8 +39,15 @@ module Hive # draft-2020-12 validator. Pass an explicit `version:` to load an # older revision (e.g. for back-compat tests against pinned # consumers). + # + # Install-aware (U2): a packaged binary has no source tree, so the + # schema files are shipped into the XDG data dir. Resolution is + # data-dir-first (~/.local/share/hive/schemas/) with a source-tree + # fallback for `git clone` / dev usage. def self.schema_dir - File.expand_path("../schemas", __dir__) + require "hive/paths" + installed = Hive::Paths.schemas_dir + File.directory?(installed) ? installed : File.expand_path("../schemas", __dir__) end def self.schema_path(name, version: nil) @@ -366,6 +373,15 @@ module Hive class TmuxError < AgentError end + # Raised by `hive update` when the install channel's native updater exits + # non-zero (brew upgrade / AUR helper / curl | bash). Maps to SOFTWARE (70) + # per the documented exit-code contract, not the generic 1. + class UpdaterError < Error + def exit_code + ExitCodes::SOFTWARE + end + end + class ConfigError < Error def exit_code ExitCodes::CONFIG diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index 9bc60cb8c..a8db2eb28 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -25,6 +25,44 @@ module Hive end map "--version" => :version + desc "update", "Update hive via the detected channel's native updater" + long_desc <<~DESC + Shelves out to the channel that installed hive (recorded in + ~/.local/state/hive/install-method): `brew upgrade /hive/hive`, + the AUR helper (default `yay -Syu hive-bin`), or re-running the + `curl | bash` one-liner. Never downloads/overwrites the binary itself + (R14) — a package-managed install stays fully managed. + + Exit codes: 0 success; 1 unknown/absent install-channel marker + (never guesses); 70 on updater failure. + DESC + def update + require "hive/commands/update" + Hive::Commands::Update.new.call + end + + desc "uninstall", "Remove hive's binary registration + daemon unit (skills untouched)" + long_desc <<~DESC + Removes hive-owned artifacts: the installed binary and the daemon OS + unit, and clears the install-method marker. A brew/AUR-managed binary + is handed off to the package manager's uninstaller (never deleted in + place). NEVER deletes user work or project `.hive-state/` dirs by + default, and agent skills are removed only via each agent's + marketplace (R15). + + --purge additionally removes XDG config/data/cache dirs, but ALWAYS + preserves ~/.local/state/hive and project `.hive-state/` (completed- + work artifacts survive even a purge). + + Exit codes: 0 success; 64 invalid flags; 70 internal error. + DESC + option :purge, type: :boolean, default: false, + desc: "also remove XDG config/data/cache (never state/ work)" + def uninstall + require "hive/commands/uninstall" + Hive::Commands::Uninstall.new(purge: options[:purge], json: options[:json]).call + end + desc "init [PROJECT_PATH]", "Bootstrap .hive-state (orphan hive/state branch); TTY-prompts for agents + limits" long_desc <<~DESC Initialises hive in PROJECT_PATH (defaults to the current directory): @@ -70,7 +108,8 @@ module Hive 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 the global config + (~/.config/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 +133,8 @@ 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 the global config (~/.config/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 @@ -595,7 +635,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 ~/.config/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/forget.rb b/lib/hive/commands/forget.rb index d2fbd014b..28aafda62 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 global registry (~/.config/hive/config.yml). 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 b86e8d610..d2508e3ba 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -3,13 +3,14 @@ require "fileutils" require "stringio" require "hive/config" require "hive/git_ops" +require "hive/service" require "hive/commands/init/prompts" require "hive/commands/doctor" module Hive module Commands class Init - def initialize(project_path, force: false, prompts: nil) + def initialize(project_path, force: false, prompts: nil, service: nil) @project_path = File.expand_path(project_path) @force = force # Optional Prompts instance for testability. Tests inject a @@ -19,6 +20,10 @@ module Hive # summary_io: $stdout)` runs (UI on stderr, machine-parseable # summary on stdout — see #collect_prompt_answers below). @prompts = prompts + # Optional Service module for testability (U3). Tests inject a + # stub (or set home_override) so unit files are written under a + # temp HOME instead of the real one. Defaults to Hive::Service. + @service = service || Hive::Service end def call @@ -44,6 +49,8 @@ module Hive entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path) + register_daemon_service(answers: answers) + print_summary(entry: entry, ops: ops) run_init_preflight! end @@ -103,6 +110,55 @@ module Hive nil end + # U3: after registration, persist the collected first-run provider/model + # into the global config (~/.config/hive/config.yml) and register the + # daemon as an OS-level service unit (launchd / systemd-user), prompting + # before enabling + starting it (never silent autostart). + def register_daemon_service(answers:) + write_first_run_config(answers) + register_os_service_unit + end + + # Write the first-run provider/model (R13) into the global config. The + # default provider ("default") with no model is a no-op so an automated + # non-interactive init does not fabricate a provider the operator never + # chose. + def write_first_run_config(answers) + default_provider = Hive::Commands::Init::Prompts::DEFAULT_PROVIDER + provider = answers["provider"] + model = answers["model"] + return if (provider.nil? || provider == default_provider) && model.nil? + + Hive::Config.upsert_first_run!(provider: provider, model: model) + end + + # Generate the daemon OS unit, then — only behind an explicit prompt — + # run the enable + start commands. The unit is written regardless so the + # operator can enable/start it later; nothing autostarts silently. + def register_os_service_unit + @service.generate + return unless autostart_confirmed? + + run_enable_start_commands! + end + + # The autostart question is asked on the real operator terminal, NOT on + # an injected @prompts instance (those are test-only and their fed input + # stream is fully consumed by collect). A fresh Prompts bound to $stdin + # both matches production (non-TTY → default no) and keeps injected- + # prompts tests free of an extra autostart input line. + def autostart_confirmed? + Hive::Commands::Init::Prompts.new( + input: $stdin, output: $stderr, summary_io: $stdout + ).autostart? + end + + def run_enable_start_commands! + @service.enable_start_commands.each do |cmd| + system(cmd) + end + end + def print_summary(entry:, ops:) c = Palette.for($stdout) name = entry["name"] diff --git a/lib/hive/commands/init/prompts.rb b/lib/hive/commands/init/prompts.rb index 088635f9c..0c5f5a477 100644 --- a/lib/hive/commands/init/prompts.rb +++ b/lib/hive/commands/init/prompts.rb @@ -28,6 +28,13 @@ module Hive DEFAULT_TRIAGE_BIAS = "courageous".freeze TRIAGE_BIASES = %w[courageous safetyist].freeze + # U3 first-run provider/model defaults. hive routes work through + # per-CLI agent profiles (claude/codex/pi), so a "default" provider + # means "let each profile decide" and a nil model means "auto / + # unspecified". These land in ~/.config/hive/config.yml (`first_run`), + # never in a project's .hive-state config. + DEFAULT_PROVIDER = "default".freeze + # Reviewer entries shipped in templates/project_config.yml.erb. The # multi-select prompt offers these as the toggleable set; rendering # honours the user's subset. The order is the stable iteration @@ -109,6 +116,8 @@ module Hive triage_bias = prompt_triage_bias budgets, timeouts = prompt_limits daemon_enabled = prompt_daemon_enabled + provider = prompt_provider + model = prompt_model answers = { "planning_agent" => planning, @@ -117,7 +126,9 @@ module Hive "triage_bias" => triage_bias, "budgets" => budgets, "timeouts" => timeouts, - "daemon_enabled" => daemon_enabled + "daemon_enabled" => daemon_enabled, + "provider" => provider, + "model" => model } summarize(answers) @@ -125,6 +136,28 @@ module Hive answers end + # U3 autostart prompt — separate from the per-project daemon + # enrollment prompt (which controls whether the daemon auto-advances a + # *project*). This controls whether init ENABLES + STARTS the OS-level + # daemon service unit (launchd / systemd-user). Defaults to no action + # (never silent autostart); non-TTY (CI) returns false without asking. + def autostart? + return false unless interactive? + + @output.puts "" + @output.puts "Register + start the hive daemon with your OS service manager" + @output.puts "(launchd on macOS / systemd-user on Linux)?" + loop do + @output.print "Enable + start the daemon now? [y/N]: " + @output.flush + answer = read_line.downcase + return false if answer.empty? || answer == "n" || answer == "no" + return true if answer == "y" || answer == "yes" + + @output.puts " please answer y or n" + end + end + # Whether prompts will fire. Public so the caller can pre-flight- # check before opening the prompt; also matches the test contract # in plan U3 (R9 testability — agents not yet installed on the @@ -143,7 +176,11 @@ module Hive "triage_bias" => DEFAULT_TRIAGE_BIAS, "budgets" => default_budgets, "timeouts" => default_timeouts, - "daemon_enabled" => true + "daemon_enabled" => true, + # Non-TTY: no first-run provider/model is collected (no guessing); + # DEFAULT_PROVIDER is still returned so callers see a stable value. + "provider" => DEFAULT_PROVIDER, + "model" => nil } # Goes to @summary_io (stdout by default) so a non-TTY caller's # `summary=$(hive init)` capture has a parseable single line. @@ -332,6 +369,23 @@ module Hive v.positive? ? v : nil end + def prompt_provider + @output.puts "" + @output.puts "First-run LLM provider/model (stored in ~/.config/hive/config.yml," + @output.puts "not in this project's config). Blank provider = default." + @output.print "Default LLM provider [#{DEFAULT_PROVIDER}]: " + @output.flush + answer = read_line + answer.empty? ? DEFAULT_PROVIDER : answer + end + + def prompt_model + @output.print "Default model (blank = auto/unspecified): " + @output.flush + answer = read_line + answer.empty? ? nil : answer + end + def prompt_daemon_enabled @output.puts "" @output.puts "Hive daemon — auto-advance tasks through the pipeline." @@ -361,6 +415,9 @@ module Hive @output.puts " triage_bias = #{answers['triage_bias']}" @output.puts " limits = #{summarize_limits(answers)}" @output.puts " daemon = #{answers['daemon_enabled'] ? 'enabled' : 'disabled'}" + if answers['provider'] || answers['model'] + @output.puts " provider/model = #{answers['provider'].inspect}/#{answers['model'].inspect}" + end end def summarize_limits(answers) diff --git a/lib/hive/commands/prune.rb b/lib/hive/commands/prune.rb index c91f02d7b..e1bdfac6b 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. + # the global config (~/.config/hive/config.yml) 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 000000000..3bfa2c173 --- /dev/null +++ b/lib/hive/commands/uninstall.rb @@ -0,0 +1,129 @@ +require "json" +require "fileutils" +require "hive" +require "hive/paths" +require "hive/install_method" +require "hive/service" + +module Hive + module Commands + # `hive uninstall` (R15 / U5). Removes the binary registration and the + # daemon OS unit, and (only behind an explicit opt-in) cleans XDG + # dirs. It never deletes user work or accumulated pipeline state by + # default, and never touches agent skills (those are removed only via + # the agent marketplace, R15). + # + # default: remove binary + daemon unit (+ install-method marker); + # prompt before removing project `.hive-state/` dirs; keep + # XDG config/data/cache/state untouched. + # --purge: also remove XDG config/data/cache (registrations + + # installed assets) but ALWAYS preserve XDG state dir + # (~/.local/state/hive) and project `.hive-state/` dirs — + # completed-work artifacts survive even a purge. + # + # A brew/aur-managed binary is handed off to the package manager's + # own uninstaller (never deleted in place); the bash one-liner binary + # (~/.local/bin/hive|hv) is deleted directly. + class Uninstall + def initialize(purge: false, json: false, runner: method(:system), service: Hive::Service) + @purge = purge + @json = json + @runner = runner + @service = service + @removed = [] + @kept = [] + @skipped = [] + end + + def call + channel = Hive::InstallMethod.channel + remove_binary!(channel) + remove_service_unit! + Hive::InstallMethod.clear + remove_xdg_dirs! if @purge + + if @json + puts JSON.generate( + "ok" => true, + "purge" => @purge, + "channel" => channel, + "removed" => @removed, + "kept" => @kept, + "skipped" => @skipped, + "note" => "Skills are NOT removed by hive uninstall; remove them via your agent's marketplace." + ) + else + print_text_summary + end + true + end + + private + + def remove_binary!(channel) + if channel == "brew" || channel == "aur" + if channel == "brew" + pkg = Hive::InstallMethod.read&.dig("package") || "ivankuznetsov/hive/hive" + @skipped << { "type" => "binary", "reason" => "package-managed", + "hint" => "brew uninstall #{pkg}" } + else + pkg = Hive::InstallMethod.read&.dig("package") || "hive-bin" + @skipped << { "type" => "binary", "reason" => "package-managed", + "hint" => "yay -Rns #{pkg}" } + end + return + end + + # One-liner (or unknown) install: remove the XDG-local binary. + chosen = Hive::InstallMethod.bin + candidates = [ File.join(Hive::Paths.bin_dir, chosen || "hive"), + File.join(Hive::Paths.bin_dir, "hive"), + File.join(Hive::Paths.bin_dir, "hv") ].uniq + candidates.select { |p| File.exist?(p) }.each do |p| + File.delete(p) + @removed << { "type" => "binary", "path" => p } + end + end + + def remove_service_unit! + path = @service.unit_path + return unless File.exist?(path) + + # Best-effort stop/disable on Linux; never fatal if systemctl is + # absent (container / desktop without systemd-user). + unless @service.macos? + @runner.call("systemctl --user stop #{@service.unit_name}.service") + @runner.call("systemctl --user disable #{@service.unit_name}.service") + @runner.call("systemctl --user daemon-reload") + end + File.delete(path) + @removed << { "type" => "service_unit", "path" => path } + end + + def remove_xdg_dirs! + # Registrations + shared installed assets only; the state dir + # (completed-work artifacts, daemon/bot logs that may matter) is + # ALWAYS preserved, even under --purge. + [ Hive::Paths.config_dir, Hive::Paths.data_dir, Hive::Paths.cache_dir ].each do |dir| + next unless File.directory?(dir) + + FileUtils.rm_rf(dir) + @removed << { "type" => "dir", "path" => dir } + end + @kept << { "type" => "dir", "path" => Hive::Paths.state_dir, + "reason" => "preserved even under --purge (work artifacts)" } + end + + def print_text_summary + if @removed.empty? && @skipped.empty? + puts "hive: nothing to uninstall." + return + end + @removed.each { |r| puts "hive: removed #{r['type']} #{r['path']}" } + @skipped.each { |s| puts "hive: #{s['type']} is #{s['reason']} — run: #{s['hint']}" } + @kept.each { |k| puts "hive: kept #{k['type']} #{k['path']} (#{k['reason']})" } + puts "hive: uninstalled. Skills are untouched — remove them via your agent's marketplace." + end + end + end +end \ No newline at end of file diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb new file mode 100644 index 000000000..9834aceda --- /dev/null +++ b/lib/hive/commands/update.rb @@ -0,0 +1,72 @@ +require "hive" +require "hive/install_method" +require "hive/paths" + +module Hive + module Commands + # `hive update` (R14 / U4): shell out to the detected channel's native + # updater and never swap the binary in place (which would corrupt a + # brew/AUR-managed install). The channel comes from the install-method + # marker that the installers write (U6); an absent/unknown marker means + # "not installed via a known channel" — refuse with guidance rather than + # guess. + class Update + # Public URL base for release artifacts (the one-liner channel). + RELEASE_URL = ENV["HIVE_RELEASE_URL"] || + "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}" + + def initialize(channel: nil, runner: method(:system), bin: nil) + @channel = channel + @runner = runner + @bin = bin + end + + def call + resolved = resolve_channel + case resolved + when "brew" then run_brew + when "aur" then run_aur + when "script" then run_script + else + raise Hive::Error, + "hive update: hive is not installed via a known channel " \ + "(no install-method marker at #{Hive::InstallMethod.marker_path}). " \ + "Install with `brew install ivankuznetsov/hive/hive`, " \ + "`yay -S hive-bin`, or the one-liner from README, then re-run." + end + end + + private + + def resolve_channel + @channel || Hive::InstallMethod.channel + end + + def run_brew + package = Hive::InstallMethod.read&.dig("package") || "ivankuznetsov/hive/hive" + run("brew upgrade #{package}") + end + + def run_aur + # The AUR helper the user installed with is recorded in the marker + # when available; default to the many-recommended `yay`. + package = Hive::InstallMethod.read&.dig("package") || "hive-bin" + helper = Hive::InstallMethod.read&.dig("helper") || "yay" + run("#{helper} -Syu #{package}") + end + + def run_script + run("curl -fsSL #{RELEASE_URL}/install.sh | bash") + end + + def run(cmd) + ok = @runner.call(cmd) + return if ok + + raise Hive::UpdaterError, + "hive update: the channel updater failed (#{cmd}); " \ + "run it manually for details" + end + end + end +end \ No newline at end of file diff --git a/lib/hive/config.rb b/lib/hive/config.rb index a67097d8d..f45ae2626 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -1,5 +1,6 @@ require "yaml" require "fileutils" +require "hive/paths" require "hive/agent_profiles" module Hive @@ -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). @@ -208,12 +209,52 @@ module Hive module_function + # Legacy pre-XDG home used only as a back-compat read fallback for the + # global config. Never a write target for a fresh install. Expanded at + # call time (not a frozen constant) so HOME is honoured dynamically. + def legacy_hive_home + File.expand_path("~/Dev/hive") + end + + # Whether the operator explicitly set HIVE_HOME (as opposed to it being + # absent). An env var set-but-empty counts as unset, so `HIVE_HOME=` on + # a CI shell can't silently yank the whole install back to the legacy + # source-tree default. + def explicit_hive_home? + v = ENV["HIVE_HOME"] + !v.nil? && !v.empty? + end + + # The container hive uses for its state files (daemon/bot pid+logs). + # Under an explicitly-set HIVE_HOME it is that directory (back-compat + # for existing installs and the test harness). Otherwise it resolves to + # the XDG state dir (~/.local/state/hive, honouring XDG_STATE_HOME) so a + # fresh install never writes to the old `~/Dev/hive` source tree. def hive_home - ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive") + return ENV["HIVE_HOME"] if explicit_hive_home? + + Hive::Paths.state_dir + end + + # Primary global-config write target: HIVE_HOME/config.yml when HIVE_HOME + # is set (back-compat), else the XDG config path (~/.config/hive/config.yml, + # honouring XDG_CONFIG_HOME). + def global_config_write_path + explicit_hive_home? ? File.join(hive_home, "config.yml") : Hive::Paths.config_path end + # Global-config READ path. Resolves in order (per U2 / assumption 1): + # 1. HIVE_HOME, when explicitly set → HIVE_HOME/config.yml + # 2. XDG config path (~/.config/hive/config.yml) when present + # 3. legacy ~/Dev/hive/config.yml — ONLY when the XDG path is absent + # AND the legacy file exists (back-compat for pre-XDG installs) + # 4. otherwise the XDG config path (first-run; caller lazy-creates) def global_config_path - File.join(hive_home, "config.yml") + return global_config_write_path if explicit_hive_home? + return Hive::Paths.config_path if File.exist?(Hive::Paths.config_path) + + legacy = File.join(legacy_hive_home, "config.yml") + File.exist?(legacy) ? legacy : Hive::Paths.config_path end def hive_state_dir(project_root, hive_state_name = ".hive-state") @@ -255,6 +296,7 @@ module Hive def registered_projects validate_hive_home! + emit_legacy_config_notice_once path = global_config_path return [] unless File.exist?(path) @@ -311,15 +353,64 @@ module Hive raise ConfigError, "global config at #{path} is not readable: #{e.message}" end - # Atomic + EACCES-aware writer for ~/Dev/hive/config.yml. Mirrors + # Whether the global config read resolves to the legacy ~/Dev/hive + # path (a pre-XDG install that hasn't been migrated yet). + def using_legacy_global_config? + return false if explicit_hive_home? + + !File.exist?(Hive::Paths.config_path) && + File.exist?(File.join(legacy_hive_home, "config.yml")) + end + + # One-time (per process) notice to stderr when the global config is + # being read from the legacy ~/Dev/hive path during the XDG transition, + # so the operator learns their config home moved. Emit only from the + # read surfaces (registered_projects / load_global_daemon / + # load_global_bot), never from path-resolution helpers called in a hot + # loop, and never from the HIVE_HOME path (which is intentional). + def emit_legacy_config_notice_once + return unless using_legacy_global_config? + return if @legacy_config_notice_emitted + + @legacy_config_notice_emitted = true + warn "hive: reading global config from legacy #{File.join(legacy_hive_home, 'config.yml')}; " \ + "hive now stores it under #{Hive::Paths.config_dir} " \ + "(move or copy it there to migrate)" + rescue Errno::EPIPE + nil + end + + # Atomic + EACCES-aware writer for the global config.yml. 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 - # (exit 78), matching the read-side classification. + # (exit 78), matching the read-side classification. Always writes to + # the primary (XDG / HIVE_HOME) target, never the legacy fallback. def write_global_config!(data) - File.write(global_config_path, data.to_yaml) + path = global_config_write_path + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, data.to_yaml) rescue Errno::EACCES, Errno::EROFS, Errno::ENOSPC => e - raise ConfigError, "global config at #{global_config_path} could not be written: #{e.message}" + raise ConfigError, "global config at #{path} could not be written: #{e.message}" + end + + # Persist the first-run provider/model captured by `hive init` (U3) + # into the global config under a `first_run:` block, preserving + # registered_projects and any other keys. Reads from the resolved read + # path (so a legacy config's existing projects survive) and writes to + # the primary (XDG / HIVE_HOME) target. `compact` drops nil model. + def upsert_first_run!(provider: nil, model: nil) + FileUtils.mkdir_p(File.dirname(global_config_write_path)) + data = if File.exist?(global_config_path) + load_global_config(global_config_path) + else + {} + end + raise ConfigError, "global config at #{global_config_path} must be a hash" unless data.is_a?(Hash) + + data["first_run"] = { "provider" => provider, "model" => model }.compact + write_global_config!(data) + data["first_run"] end def find_project(name) @@ -337,6 +428,7 @@ module Hive # present (first-run scenario, no projects registered yet). def load_global_daemon validate_hive_home! + emit_legacy_config_notice_once path = global_config_path data = File.exist?(path) ? load_global_config(path) : {} raise ConfigError, "global config at #{path} must be a hash" unless data.is_a?(Hash) @@ -361,6 +453,7 @@ module Hive # `hive status` require a Telegram token. def load_global_bot(require_runtime: false) validate_hive_home! + emit_legacy_config_notice_once path = global_config_path data = File.exist?(path) ? load_global_config(path) : {} raise ConfigError, "global config at #{path} must be a hash" unless data.is_a?(Hash) @@ -393,7 +486,7 @@ module Hive end def register_project(name:, path:) - FileUtils.mkdir_p(hive_home) + FileUtils.mkdir_p(File.dirname(global_config_write_path)) data = if File.exist?(global_config_path) load_global_config(global_config_path) else diff --git a/lib/hive/install_method.rb b/lib/hive/install_method.rb new file mode 100644 index 000000000..66fdd3814 --- /dev/null +++ b/lib/hive/install_method.rb @@ -0,0 +1,65 @@ +require "yaml" +require "fileutils" +require "hive/paths" + +module Hive + # Records which channel installed the hive binary (U4 / U5 / U6). + # + # The installers (brew formula, AUR PKGBUILD, the bash one-liner) write a + # marker file under the XDG state dir describing { channel, package, version, + # bin }. `hive update` shells out to that channel's native updater and never + # swaps the binary in place (R14); `hive uninstall` uses the marker to hand + # removal off to the channel's uninstaller where managed (R15); the daemon + # service generation uses the recorded `bin` (hive vs hv) so the unit always + # matches how hive is actually installed. + # + # An absent/corrupt marker is NOT inferred from the filesystem — callers must + # treat it as "unknown channel" and refuse to guess. + module InstallMethod + module_function + + def marker_path + File.join(Hive::Paths.state_dir, "install-method") + end + + # Read the marker as a Hash with String keys { channel, package, version, + # bin } (only those present on disk), or nil when absent/corrupt. + def read + return nil unless File.exist?(marker_path) + + data = YAML.safe_load(File.read(marker_path)) + data.is_a?(Hash) ? data : nil + rescue Psych::Exception, Errno::EACCES + nil + end + + def channel + read&.dig("channel") + end + + def bin + read&.dig("bin") + end + + # Write/replace the marker. channel must be one of the known channels. + # Extra fields (package / version / bin) are stored when provided so + # update/uninstall/service stay consistent with the real install. + CHANNELS = %w[brew aur script].freeze + + def write(channel:, package: nil, version: nil, bin: nil) + raise ArgumentError, "unknown channel #{channel.inspect}" unless CHANNELS.include?(channel) + + data = { "channel" => channel } + data["package"] = package if package + data["version"] = version if version + data["bin"] = bin if bin + FileUtils.mkdir_p(File.dirname(marker_path)) + File.write(marker_path, data.to_yaml) + data + end + + def clear + File.delete(marker_path) if File.exist?(marker_path) + end + end +end \ No newline at end of file diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb new file mode 100644 index 000000000..e8cbe5334 --- /dev/null +++ b/lib/hive/paths.rb @@ -0,0 +1,85 @@ +module Hive + # XDG Base Directory resolution for hive's on-disk layout. + # + # Before a packaged/installable hive existed, every on-disk path — + # the global `config.yml`, daemon/bot logs and pid files, the + # `hive_home` default — was hardcoded to the source tree + # `~/Dev/hive`. A single-file vendored-Ruby binary has no source + # tree to hang paths off, so hive must follow the XDG Base Directory + # spec for an installable layout: + # + # ~/.config/hive config dir → the global config.yml + # ~/.local/share/hive data dir → shipped templates/ + schemas/ + # ~/.local/state/hive state dir → daemon/bot pid+logs, state files + # ~/.cache/hive cache dir → derived / scratch caches + # ~/.local/bin/hive bin dir → the installed binary (one-liner) + # + # Every dir honours its XDG_*_HOME environment override. An explicitly + # set `HIVE_HOME` continues to override the state dir (and via + # Hive::Config, the config file), preserving the legacy single-container + # behaviour for existing installs and the test harness. + module Paths + module_function + + def home + File.expand_path(Dir.home) + end + + # Expand an XDG_*_HOME override, or return the standard default when + # the override is unset/blank (an env var set-but-empty is treated as + # unset, matching the XDG spec's "empty value == unset" reading). + def env_dir(env, default) + v = ENV[env] + (v.nil? || v.empty?) ? default : File.expand_path(v) + end + + def xdg_config_home + env_dir("XDG_CONFIG_HOME", File.join(home, ".config")) + end + + def xdg_data_home + env_dir("XDG_DATA_HOME", File.join(home, ".local", "share")) + end + + def xdg_state_home + env_dir("XDG_STATE_HOME", File.join(home, ".local", "state")) + end + + def xdg_cache_home + env_dir("XDG_CACHE_HOME", File.join(home, ".cache")) + end + + def config_dir + File.join(xdg_config_home, "hive") + end + + def config_path + File.join(config_dir, "config.yml") + end + + def data_dir + File.join(xdg_data_home, "hive") + end + + def templates_dir + File.join(data_dir, "templates") + end + + def schemas_dir + File.join(data_dir, "schemas") + end + + def state_dir + File.join(xdg_state_home, "hive") + end + + def cache_dir + File.join(xdg_cache_home, "hive") + end + + # Where the one-liner installer drops the binary (R8). + def bin_dir + File.join(home, ".local", "bin") + end + end +end \ No newline at end of file diff --git a/lib/hive/service.rb b/lib/hive/service.rb new file mode 100644 index 000000000..e80974395 --- /dev/null +++ b/lib/hive/service.rb @@ -0,0 +1,191 @@ +require "fileutils" +require "hive/paths" +require "hive/install_method" + +module Hive + # Daemon service registration for `hive init` (U3 / R12). + # + # Generates a launchd agent (macOS) or a systemd-user unit (Linux) that + # runs the `hive daemon` dispatcher, baking in the resolved hive binary + # path. The unit is written by init; whether it is ENABLED and STARTED is + # a separate, explicit prompt ("enable + start now?") that defaults to no + # action — init never autostarts silently regardless of platform. + # + # Unit paths: + # macOS: ~/Library/LaunchAgents/local.hive-daemon.plist + # Linux: ~/.config/systemd/user/hive-daemon.service + module Service + module_function + + def self.home_override + @home_override + end + + def self.home_override=(dir) + @home_override = dir ? File.expand_path(dir) : nil + end + + def home + Hive::Service.home_override || Hive::Paths.home + end + + def macos? + RUBY_PLATFORM.include?("darwin") + end + + def launchd_unit_path + File.join(home, "Library", "LaunchAgents", "local.hive-daemon.plist") + end + + def systemd_unit_path + File.join(home, ".config", "systemd", "user", "hive-daemon.service") + end + + def unit_dir + macos? ? File.dirname(launchd_unit_path) : File.dirname(systemd_unit_path) + end + + def unit_path + macos? ? launchd_unit_path : systemd_unit_path + end + + def unit_name + macos? ? "local.hive-daemon" : "hive-daemon" + end + + # The hive binary path to bake into the unit. Precedence: an explicit + # HIVE_BIN override (tests / exotic layouts), else the path this process + # was launched from when it resolves to a real installed binary, else the + # bin name recorded by the install-method marker (hive vs hv) resolved on + # PATH or from the XDG bin dir, else the XDG one-liner bin dir default. + def binary_path + v = ENV["HIVE_BIN"] + return v if v && !v.empty? + + prog = begin + $PROGRAM_NAME.to_s + rescue StandardError + "" + end + # Only treat the running process as the installed hive when it resolves + # to a real `hive`/`hv` binary AND is not the source-checkout dev shim. + # A generic Ruby entry point (e.g. a test runner) must never be baked + # into the unit as the daemon binary. + resolved = resolve_program(prog) unless prog.empty? + return resolved if resolved + + # The install-method marker records the bin NAME (hive vs hv); resolve + # it to a real path via PATH (brew/AUR/one-liner) then the XDG bin dir. + bin_name = Hive::InstallMethod.bin + if bin_name && !bin_name.empty? + on_path = which(bin_name) + return on_path if on_path + + return File.join(Hive::Paths.bin_dir, bin_name) + end + + File.join(Hive::Paths.bin_dir, "hive") + end + + # Resolve $PROGRAM_NAME to the real installed binary path, or nil when it + # is not a hive binary (bare name not on PATH, dev shim, test runner, …). + def resolve_program(prog) + path = File.exist?(prog) ? File.realpath(prog) : which(File.basename(prog)) + return nil if path.nil? + return nil unless %w[hive hv].include?(File.basename(path)) + return nil if dev_shim?(path) + + path + rescue StandardError + nil + end + + # Look up `name` on PATH, returning the first executable hit's realpath. + def which(name) + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir| + next if dir.empty? + + candidate = File.join(dir, name) + return File.realpath(candidate) if File.file?(candidate) && File.executable?(candidate) + rescue Errno::EACCES + next + end + nil + end + + # The source-checkout dev shim lives at /bin/hive and loads hive + # from /lib. Detect it by that sibling source tree; a real installed + # binary has no lib/hive.rb sitting next to it. + def dev_shim?(bin_path) + root = File.dirname(File.dirname(bin_path)) + File.exist?(File.join(root, "lib", "hive.rb")) + end + + def render_launchd_plist(bin) + <<~PLIST + + + + + Label + local.hive-daemon + ProgramArguments + + #{bin} + daemon + start + + RunAtLoad + + KeepAlive + + + + PLIST + end + + def render_systemd_unit(bin) + <<~UNIT + [Unit] + Description=Hive workflow daemon + After=network-online.target + + [Service] + Type=simple + ExecStart=#{bin} daemon start + Restart=on-failure + RestartSec=5 + StartLimitBurst=5 + StartLimitIntervalSec=60 + + [Install] + WantedBy=default.target + UNIT + end + + # Write the unit for the current platform. Returns the unit path. + def generate + FileUtils.mkdir_p(unit_dir) + content = macos? ? render_launchd_plist(binary_path) : render_systemd_unit(binary_path) + File.write(unit_path, content) + unit_path + end + + def unit_present? + File.exist?(unit_path) + end + + # The shell commands that would ENABLE + START the unit. Not executed + # here — `hive init` runs them only behind an explicit prompt. + def enable_start_commands + if macos? + [ "launchctl load -w #{unit_path}" ] + else + [ + "systemctl --user daemon-reload", + "systemctl --user enable --now #{unit_name}.service" + ] + end + end + end +end \ No newline at end of file diff --git a/lib/hive/stages/base.rb b/lib/hive/stages/base.rb index 7d19d3cfd..59902148b 100644 --- a/lib/hive/stages/base.rb +++ b/lib/hive/stages/base.rb @@ -4,6 +4,7 @@ require "securerandom" require "time" require "hive/agent" require "hive/agent_profiles" +require "hive/paths" module Hive module Stages @@ -23,8 +24,24 @@ module Hive "user_supplied_#{SecureRandom.hex(8)}" end + # Install-aware built-in template path (U2). A packaged binary has no + # source tree, so shared templates are shipped into the XDG data dir; + # resolution is data-dir-first with a source-tree fallback for dev / + # `git clone` usage. Existence is checked so a packaged binary whose + # data asset is missing fails loudly at render time rather than + # silently writing an ERB template filename into the prompt. + def builtin_template_path(name) + installed = File.join(Hive::Paths.templates_dir, name) + return installed if File.exist?(installed) + + File.expand_path("../../../templates/#{name}", __dir__) + end + + # Install-aware render: resolve the built-in (or custom) path then + # render it. For built-ins this honours the XDG data dir; custom + # (slashed) paths still flow through resolve_template_path's jail. def render(template_name, bindings_obj) - path = File.expand_path("../../../templates/#{template_name}", __dir__) + path = builtin_template_path(template_name) ERB.new(File.read(path), trim_mode: "-").result(bindings_obj.binding_for_erb) end @@ -60,8 +77,8 @@ module Hive raise Hive::ConfigError, "prompt_template name cannot be blank" if name.nil? || name.to_s.empty? if !name.include?("/") && !File.absolute_path?(name) - # Built-in template lookup. - builtin = File.expand_path("../../../templates/#{name}", __dir__) + # Built-in template lookup (install-aware: data dir first). + builtin = builtin_template_path(name) unless File.exist?(builtin) raise Hive::ConfigError, "prompt_template #{name.inspect} not found among built-ins (#{builtin})" diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 000000000..3c17cc398 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,74 @@ +# hive — install channels + +hive ships as a **self-contained, runtime-free binary** (a vendored-Ruby +binary built by the U1 release pipeline) across three channels and one +published prompt. Every channel downloads the **same** GitHub Release +artifact and verifies its SHA-256 against `SHA256SUMS`. + +## Channels + +### 1. Homebrew (macOS, tier-1) + +```sh +brew tap ivankuznetsov/hive # the org's tap: `homebrew-hive` +brew install ivankuznetsov/hive/hive +``` + +Installs to `/opt/homebrew/bin/hive` (Apple Silicon) or `/usr/local/bin` +(x86_64). The formula is bottled-style (`packaging/homebrew/hive.rb`): it +downloads the prebuilt binary and has **no** `depends_on` — no system Ruby. + +### 2. Arch (AUR `hive-bin`) + +```sh +yay -S hive-bin # or your AUR helper of choice +``` + +`packaging/aur/PKGBUILD` is a prebuilt (`-bin`) package; the binary lands in +a package-managed path. Install the `-git` package for bleeding-edge source +builds (deferred; not part of v1). + +### 3. Bash one-liner (Ubuntu / Debian / Fedora / WSL2, tier-1/2) + +```sh +curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash +``` + +`packaging/install.sh` detects OS/arch, downloads + SHA-256-verifies the +binary, drops it at `~/.local/bin/hive` (or `~/.local/bin/hv` on a name +conflict — R10/R9), writes the install-method marker, and reports missing +runtime deps (`git`, `bash`, `gh`, `jq`, `claude`) with hints. It never +auto-installs a dependency. + +## Name-conflict fallback (`hv`) + +If a non-hive `hive` is already on `PATH` (e.g. Apache Hive), the one-liner +installs as `~/.local/bin/hv`. The choice is recorded in the install-method +marker, so `hive update`, `hive uninstall`, and daemon service generation +stay consistent. AUR is always named `hive-bin`, so its binary is `hive` +regardless. + +## Install-method marker + +All channels write `~/.local/state/hive/install-method` +(`{channel, package, version, bin}`). The core binary **does not install +agent skills** (R7) — skills live in a separate `hive-skills` package +installed only through each agent's marketplace (see `docs/skills-package.md`, +U8). + +## Update & uninstall + +```sh +hive update # shells out to the channel's native updater (R14) +brew upgrade ivankuznetsov/hive/hive # or: yay -Syu hive-bin, or re-run the one-liner +hive uninstall # remove binary registration + daemon unit; skills untouched (R15) +``` + +`hive update` never overwrites a package-managed binary in place. + +## Pinning releases + +All channels pin a single semver (v0.1.0). Artifacts + `SHA256SUMS` are +published by the tag-driven release workflow (U1). The formula and PKGBUILD +`sha256` values are placeholders until the v0.1.0 release publishes real +checksums. \ No newline at end of file diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 000000000..16d048b11 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: the hive project +# hive-bin: prebuilt, runtime-free binary package (R4). Requires no system +# Ruby — a vendored-Ruby binary is pulled from GitHub Releases. +# +# Publish with: +# # sha256sums fill via the U1 release artifact +# updpkgsums && makepkg && namcap hive-bin.pkg.tar.zst +# +# The package is `hive-bin` regardless of any `hive` name on PATH (R10); +# the binary is installed to a package-managed path and symlinked as `hive`. + +pkgname=hive-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Agent-driven workflow orchestration (hive daemon + CLI + TUI)" +arch=('x86_64' 'aarch64') +url='https://github.com/ivankuznetsov/hive' +license=('MIT') +depends=() +makedepends=() +install=hive-bin.install +source_x86_64=("hive-linux-x86_64::https://github.com/ivankuznetsov/hive/releases/download/v${pkgver}/hive-linux-x86_64") +source_aarch64=("hive-linux-aarch64::https://github.com/ivankuznetsov/hive/releases/download/v${pkgver}/hive-linux-aarch64") +# sha256sums filled by the U1 release job from SHA256SUMS: +sha256sums=('0000000000000000000000000000000000000000000000000000000000000000') +sha256sums_aarch64=('0000000000000000000000000000000000000000000000000000000000000000') + +package() { + local src + case "$CARCH" in + x86_64) src="hive-linux-x86_64" ;; + aarch64) src="hive-linux-aarch64" ;; + esac + install -Dm755 "${srcdir}/${src}" "${pkgdir}/usr/bin/hive" +} + +# After install, the `hive-bin.install` hook writes the install-method marker +# into ~/.local/state/hive/install-method (channel=aur) so `hive update` (U4) +# shells out to the AUR helper (default yay) and `hive uninstall` hands the +# binary back to the package manager. diff --git a/packaging/aur/hive-bin.install b/packaging/aur/hive-bin.install new file mode 100644 index 000000000..54cfaa80a --- /dev/null +++ b/packaging/aur/hive-bin.install @@ -0,0 +1,37 @@ +# hive-bin pacman install hooks (U4/U6). +# +# Writes the install-method marker into ~/.local/state/hive/install-method on +# install/upgrade so `hive update` (U4) shells out to the AUR helper and +# `hive uninstall` (R15) hands the binary back to the package manager instead +# of guessing. The helper used to install is not knowable from inside pacman, +# so the marker records only channel/package/version/bin; `hive update` +# defaults to `yay` when no helper is recorded. + +_marker_dir() { + printf '%s/hive' "${XDG_STATE_HOME:-$HOME/.local/state}" +} + +_write_marker() { + local state_dir + state_dir="$(_marker_dir)" + mkdir -p "$state_dir" + cat > "$state_dir/install-method" <<'EOF' +--- +channel: aur +package: hive-bin +version: 0.1.0 +bin: hive +EOF +} + +post_install() { + _write_marker +} + +post_upgrade() { + _write_marker +} + +post_remove() { + rm -f "$(_marker_dir)/install-method" +} diff --git a/packaging/homebrew/hive.rb b/packaging/homebrew/hive.rb new file mode 100644 index 000000000..535c2dbec --- /dev/null +++ b/packaging/homebrew/hive.rb @@ -0,0 +1,69 @@ +# Homebrew formula for hive (bottled-style: downloads the prebuilt, runtime-free +# binary from GitHub Releases — no system Ruby, no build step). +# +# Usage: +# brew tap ivankuznetsov/hive # tap under the org (repo: homebrew-hive) +# brew install ivankuznetsov/hive/hive +# +# The release pipeline (U1) publishes one self-contained binary per tier-1 +# target and the matching SHA-256. The `sha256` values below are filled by the +# U1 release job (they are placeholders until v0.1.0 ships). Keep Hive::VERSION +# and this `version` in lockstep — the release workflow asserts tag == version. + +class Hive < Formula + desc "Agent-driven workflow orchestration (hive daemon + CLI + TUI)" + homepage "https://github.com/ivankuznetsov/hive" + license "MIT" + version "0.1.0" + + # No runtime dependencies: hive ships a vendored-Ruby binary. + + if OS.mac? + if Hardware::CPU.arm? + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-darwin-arm64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" # SHA256SUMS placeholder + else + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-darwin-x86_64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" # SHA256SUMS placeholder + end + elsif OS.linux? + if Hardware::CPU.arm? + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-linux-aarch64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" # SHA256SUMS placeholder + else + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-linux-x86_64" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" # SHA256SUMS placeholder + end + end + + # The release tarball ships a single executable per-target; the formula + # publishes it under the canonical `hive` name (symlinked into $(brew --prefix)/bin). + def install + bin.install Dir["hive-*"].first => "hive" + end + + # U4/U6: write the install-method marker so `hive update`/`hive uninstall`/ + # daemon service generation know this was a brew-managed install. + def post_install + require "yaml" + xdg_state = ENV["XDG_STATE_HOME"] + state_home = if xdg_state.nil? || xdg_state.empty? + File.join(Dir.home, ".local", "state") + else + xdg_state + end + state_dir = File.join(state_home, "hive") + FileUtils.mkdir_p(state_dir) + marker = { + "channel" => "brew", + "package" => "ivankuznetsov/hive/hive", + "version" => version, + "bin" => "hive" + } + File.write(File.join(state_dir, "install-method"), marker.to_yaml) + end + + test do + system "#{bin}/hive", "--version" + end +end \ No newline at end of file diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100644 index 000000000..53479671c --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# hive one-liner installer — `curl -fsSL /install.sh | bash`. +# +# Detects OS + arch, downloads the prebuilt runtime-free hive binary from +# GitHub Releases, verifies its SHA-256 against SHA256SUMS, drops it at +# ~/.local/bin/{hive|hv}, writes the install-method marker (U4/U5/U6) and +# reports missing runtime deps with hints (R9). +# +# Overrides: +# HIVE_VERSION version to install (default: 0.1.0) +# HIVE_RELEASE_URL release base URL (default: GitHub releases) +# HIVE_INSTALL_BIN force the bin name (hive|hv); otherwise auto-detect +set -euo pipefail + +HIVE_VERSION="${HIVE_VERSION:-0.1.0}" +org_repo="${HIVE_REPO:-ivankuznetsov/hive}" +BASE_URL="${HIVE_RELEASE_URL:-https://github.com/${org_repo}/releases/download/v${HIVE_VERSION}}" +BIN_DIR="${HIVE_BIN_DIR:-$HOME/.local/bin}" + +# ---- OS / arch detection (tier-1: linux x86_64, macOS arm64; see R16) -- +case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) echo "hive: unsupported OS '$(uname -s)' (tier-1: macOS arm64, Ubuntu 22.04+, Arch)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch="x86_64" ;; + arm64|aarch64) arch="aarch64" ;; + *) echo "hive: unsupported arch '$(uname -m)'" >&2; exit 1 ;; +esac + +# Release-asset tokens: macOS arm64 -> "arm64", Linux arm64 -> "aarch64"; +# x86_64 stays "x86_64" everywhere. macOS x86_64 is tier-2 (best-effort, R16). +if [ "$os" = "darwin" ]; then + if [ "$arch" = "aarch64" ]; then + arch="arm64" + else + echo "hive: macOS x86_64 is tier-2 (best-effort); continuing" >&2 + fi +fi + +asset="${os}-${arch}" +url="${BASE_URL}/hive-${asset}" +sums_url="${BASE_URL}/SHA256SUMS" + +# ---- name-conflict fallback (R10) -------------------------------------- +detect_bin_name() { + # If a non-hive `hive` is already on PATH (e.g. Apache Hive), install as `hv`. + for d in $(echo "$PATH" | tr ':' ' '); do + [ -x "$d/hive" ] || continue + # A hive we own is either the one we're about to overwrite, or brew/aur + # managed. Only avoid the conflict for an ours/absent bin. + if [ "$(readlink -f "$d/hive" 2>/dev/null || echo "$d/hive")" = "$BIN_DIR/hive" ] || [ -f "${XDG_STATE_HOME:-$HOME/.local/state}/hive/install-method" ]; then + echo "hive" ; return + fi + echo "hv" ; return + done + echo "hive" +} + +bin_name="${HIVE_INSTALL_BIN:-$(detect_bin_name)}" + +mkdir -p "$BIN_DIR" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "hive: downloading ${url}" +curl -fsSL "$url" -o "$tmp/hive" +chmod +x "$tmp/hive" + +# ---- SHA-256 verification ---------------------------------------------- +if curl -fsSL "$sums_url" -o "$tmp/SHA256SUMS" 2>/dev/null; then + expected="$(awk -v a="hive-${asset}" '$2==a || $1==a {print $1; exit}' "$tmp/SHA256SUMS")" + if [ -n "$expected" ]; then + actual="$(shasum -a 256 "$tmp/hive" | awk '{print $1}')" + if [ "$actual" != "$expected" ]; then + echo "hive: SHA-256 mismatch for hive-${asset} (got $actual, want $expected); aborting" >&2 + exit 1 + fi + echo "hive: sha256 verified" + else + echo "hive: no SHA-256 entry for hive-${asset}; skipping verification (found: $(head -1 "$tmp/SHA256SUMS"))" >&2 + fi +else + echo "hive: SHA256SUMS not available; skipping verification" >&2 +fi + +install -m 0755 "$tmp/hive" "$BIN_DIR/$bin_name" +echo "hive: installed ${bin_name} -> $BIN_DIR/$bin_name" + +# ---- install-method marker (U4/U5/U6) ---------------------------------- +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/hive" +mkdir -p "$state_dir" +{ + cat < "$state_dir/install-method" + +# ---- runtime dependency detection (R9, never auto-installs) ------------ +echo "" +echo "hive: checking runtime dependencies (hint-only, never auto-installs):" +for dep in git bash gh jq claude; do + if command -v "$dep" >/dev/null 2>&1; then + echo " [ok] $dep" + else + case "$dep" in + git) hint="brew install git / apt-get install git / pacman -S git" ;; + bash) hint="bash is required; install your distro's bash package" ;; + gh) hint="brew install gh / apt-get install gh / pacman -S gh cli" ;; + jq) hint="brew install jq / apt-get install jq / pacman -S jq" ;; + claude) hint="install the Claude Code CLI (hive routes agent work through it)" ;; + esac + echo " [MISS] $dep (${hint})" + fi +done + +echo "" +echo "hive: installing is complete. Next: run 'hive init' in a git repo, or 'hive --help'." \ No newline at end of file diff --git a/templates/hive_config.yml.erb b/templates/hive_config.yml.erb index bb8d36749..0b7e8188b 100644 --- a/templates/hive_config.yml.erb +++ b/templates/hive_config.yml.erb @@ -20,8 +20,10 @@ registered_projects: <%= registered_projects.empty? ? "[]" : "" %> # 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 +# # Defaults below resolve under the XDG state dir (~/.local/state/hive) +# # unless HIVE_HOME is set; the commented values are placeholders. +# 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 diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb index 0b4238612..b27615ec1 100644 --- a/test/integration/init_test.rb +++ b/test/integration/init_test.rb @@ -1,9 +1,23 @@ require "test_helper" require "hive/commands/init" +require "hive/service" class InitTest < Minitest::Test include HiveTestHelper + # U3: init now writes a daemon OS unit. Keep that hermetic by pointing the + # service home at a temp dir so no unit lands in the real user home during + # tests. + def setup + @service_home = Dir.mktmpdir("hive-service-home") + Hive::Service.home_override = @service_home + end + + def teardown + Hive::Service.home_override = nil + FileUtils.rm_rf(@service_home) if @service_home + end + def test_initializes_project_with_orphan_branch_and_global_registration with_tmp_global_config do with_tmp_git_repo do |dir| @@ -207,14 +221,14 @@ class InitTest < Minitest::Test def test_init_with_piped_user_choices_writes_matching_config # Order matches Prompts#collect: planning, development, reviewers, - # triage bias, 9 limit prompts, daemon-enable, confirm. Choose codex - # for both, safetyist triage, only first + third reviewer, override - # `plan` budget/timeout, accept the rest (daemon defaults to enabled on - # blank, confirm defaults to yes on blank). + # triage bias, 9 limit prompts, daemon-enable, provider, model, confirm. + # Choose codex for both, safetyist triage, only first + third reviewer, + # override `plan` budget/timeout, accept the rest (daemon defaults to + # enabled on blank, provider/model blank, confirm defaults to yes on blank). inputs = [ "codex", "2", "1,3", "safetyist", "", "30,900", "", "", "", "", "", "", "", - "", "" + "", "", "", "" ].join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| @@ -244,7 +258,8 @@ class InitTest < Minitest::Test def test_init_with_daemon_disabled_writes_disabled_config # Same shape as above but explicitly answer `n` to the daemon prompt. - inputs = (([ "" ] * 13) + [ "n", "" ]).join("\n") + "\n" + # Trailing: daemon(n), provider(blank), model(blank), confirm(blank). + inputs = (([ "" ] * 13) + [ "n", "", "", "" ]).join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| prompts = make_tty_prompts(inputs) @@ -257,10 +272,57 @@ class InitTest < Minitest::Test end end + # U3: interactive init captures first-run provider/model into the global + # (~/.config/hive / HIVE_HOME) config, and registers the daemon OS unit + # (systemd-user on Linux) without autostarting it (the autostart prompt + # reads real $stdin, which is non-TTY in tests -> default no action). + def test_init_writes_first_run_config_and_daemon_unit + bin = "/usr/local/bin/hive" + old_bin = ENV["HIVE_BIN"] + ENV["HIVE_BIN"] = bin + inputs = [ + "claude", "codex", "", "", + "", "", "", "", "", "", "", "", "", + "", "anthropic", "claude-sonnet-4-5", "" + ].join("\n") + "\n" + with_tmp_global_config do |home| + with_tmp_git_repo do |dir| + prompts = make_tty_prompts(inputs) + capture_io { Hive::Commands::Init.new(dir, prompts: prompts).call } + + global = YAML.safe_load(File.read(File.join(home, "config.yml"))) + assert_equal "anthropic", global.dig("first_run", "provider") + assert_equal "claude-sonnet-4-5", global.dig("first_run", "model") + assert global["registered_projects"].any? { |pr| pr["name"] == File.basename(dir) } + + unit = Hive::Service.unit_path + assert File.exist?(unit), "hive init must write the daemon OS unit" + content = File.read(unit) + assert_includes content, "ExecStart=#{bin} daemon start" + end + end + ensure + old_bin ? ENV["HIVE_BIN"] = old_bin : ENV.delete("HIVE_BIN") + end + + # U3: a non-interactive (CI / non-TTY) init must not fabricate a provider; + # the default provider with no model is a no-op write. + def test_init_non_interactive_does_not_write_first_run_provider + with_tmp_global_config do |home| + with_tmp_git_repo do |dir| + capture_io { Hive::Commands::Init.new(dir).call } + global = YAML.safe_load(File.read(File.join(home, "config.yml"))) + assert_nil global["first_run"], + "non-interactive init must not fabricate a first-run provider" + end + end + end + def test_init_aborts_with_zero_disk_state_when_user_says_n # Blank for everything until confirmation; answer `n` at the end. - # 14 blanks: planning, dev, reviewers, triage bias, 9 limits, daemon-enable. - inputs = (([ "" ] * 14) + [ "n" ]).join("\n") + "\n" + # 16 blanks: planning, dev, reviewers, triage bias, 9 limits, daemon, + # provider, model. Then `n` on confirm. + inputs = (([ "" ] * 16) + [ "n" ]).join("\n") + "\n" with_tmp_global_config do with_tmp_git_repo do |dir| prompts = make_tty_prompts(inputs) diff --git a/test/unit/commands/init/prompts_test.rb b/test/unit/commands/init/prompts_test.rb index 734b955d9..d97fc9a0d 100644 --- a/test/unit/commands/init/prompts_test.rb +++ b/test/unit/commands/init/prompts_test.rb @@ -46,7 +46,9 @@ class InitPromptsTest < Minitest::Test "timeouts" => Hive::Commands::Init::Prompts::LIMIT_KEYS.each_with_object({}) do |k, h| h[k] = Hive::Config::DEFAULTS["timeout_sec"][k] end, - "daemon_enabled" => true + "daemon_enabled" => true, + "provider" => Hive::Commands::Init::Prompts::DEFAULT_PROVIDER, + "model" => nil } end @@ -62,8 +64,10 @@ class InitPromptsTest < Minitest::Test # 15. confirmation Y/n # Each line is one answer; blank line = accept default. def interactive_input(planning: "", development: "", reviewers: "", - triage_bias: "", limits: ([ "" ] * 9), daemon: "", confirm: "") - [ planning, development, reviewers, triage_bias, *limits, daemon, confirm ].map { |a| "#{a}\n" }.join + triage_bias: "", limits: ([ "" ] * 9), daemon: "", + confirm: "", provider: "default", model: "") + [ planning, development, reviewers, triage_bias, *limits, daemon, + provider, model, confirm ].map { |a| "#{a}\n" }.join end # --- non-TTY: short-circuit to defaults ---------------------------------- @@ -141,7 +145,7 @@ class InitPromptsTest < Minitest::Test # 16 reads total: planning (invalid + retry) + dev + reviewers # + triage bias + 9 limits + daemon + confirm. Each blank line # accepts the default. - raw = ([ "nonexistent", "claude" ] + ([ "" ] * 14)).join("\n") + "\n" + raw = ([ "nonexistent", "claude" ] + ([ "" ] * 16)).join("\n") + "\n" prompts, output, _summary = make_prompts(raw) answers = prompts.collect assert_equal "claude", answers["planning_agent"] @@ -149,7 +153,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_planning_agent_index_out_of_range_reprompts - raw = ([ "7", "claude" ] + ([ "" ] * 14)).join("\n") + "\n" + raw = ([ "7", "claude" ] + ([ "" ] * 16)).join("\n") + "\n" prompts, output, _summary = make_prompts(raw) answers = prompts.collect assert_equal "claude", answers["planning_agent"] @@ -189,7 +193,7 @@ class InitPromptsTest < Minitest::Test # Build the input manually since interactive_input doesn't allow # multi-line reviewer answers cleanly. Trailing values are triage # bias, nine limits, daemon, and confirm. - input = ([ "", "", "7", "1,2" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", "7", "1,2" ] + ([ "" ] * 14)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review codex-ce-code-review], answers["enabled_reviewers"] @@ -197,7 +201,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_reviewers_unknown_name_reprompts - input = ([ "", "", "nope", "1" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", "nope", "1" ] + ([ "" ] * 14)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"] @@ -231,7 +235,7 @@ class InitPromptsTest < Minitest::Test end def test_interactive_triage_bias_unknown_reprompts - input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 11)).join("\n") + "\n" + input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 13)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal "safetyist", answers["triage_bias"] @@ -278,7 +282,7 @@ class InitPromptsTest < Minitest::Test def test_interactive_limits_zero_budget_reprompts # First answer 0,300 fails validation → re-prompt; second answer 10,600 accepted. # Trailing pair is daemon + confirm (both blank → daemon enabled, confirm yes). - input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 10)).join("\n") + "\n" + input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 12)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal 10, answers["budgets"]["brainstorm"] @@ -288,7 +292,7 @@ class InitPromptsTest < Minitest::Test def test_interactive_limits_malformed_format_reprompts # "30" without comma fails the , shape → re-prompt - input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 10)).join("\n") + "\n" + input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 12)).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal 30, answers["budgets"]["brainstorm"] @@ -347,7 +351,7 @@ class InitPromptsTest < Minitest::Test # daemon slot. interactive_input(daemon: "maybe") provides "maybe" + "" (confirm). # We need: maybe (rejected) → y (accepted as daemon=enabled) → blank (confirm). # So push two extra reads in. - input = interactive_input(daemon: "maybe", confirm: "") + "y\n\n" + input = ([ "", "", "", "" ] + ([ "" ] * 9) + [ "maybe", "y", "", "", "" ]).join("\n") + "\n" prompts, output = make_prompts(input) answers = prompts.collect assert_equal true, answers["daemon_enabled"] @@ -494,7 +498,7 @@ class InitPromptsTest < Minitest::Test # validate_reviewers! rejects on the next `hive run`. Re-prompt instead. def test_reviewers_comma_only_reprompts # Trailing values are triage bias, nine limits, daemon, and confirm. - input = ([ "", "", ",", "1" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", ",", "1" ] + ([ "" ] * 14)).join("\n") + "\n" prompts, output, _summary = make_prompts(input) answers = prompts.collect assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"] @@ -502,7 +506,7 @@ class InitPromptsTest < Minitest::Test end def test_reviewers_whitespace_only_reprompts - input = ([ "", "", " , , ", "2" ] + ([ "" ] * 12)).join("\n") + "\n" + input = ([ "", "", " , , ", "2" ] + ([ "" ] * 14)).join("\n") + "\n" prompts, output, _summary = make_prompts(input) answers = prompts.collect assert_equal %w[codex-ce-code-review], answers["enabled_reviewers"] diff --git a/test/unit/install_method_test.rb b/test/unit/install_method_test.rb new file mode 100644 index 000000000..c575d6354 --- /dev/null +++ b/test/unit/install_method_test.rb @@ -0,0 +1,57 @@ +require "test_helper" +require "hive/install_method" +require "hive/paths" + +# U4/U6 — the install-method marker written by the installers. +class InstallMethodTest < Minitest::Test + include HiveTestHelper + + def setup + @old_home = ENV["HOME"] + @home = Dir.mktmpdir("hive-im-home") + ENV["HOME"] = @home + end + + def teardown + ENV["HOME"] = @old_home + FileUtils.rm_rf(@home) if @home + end + + def test_read_returns_nil_when_absent + assert_nil Hive::InstallMethod.read + assert_nil Hive::InstallMethod.channel + end + + def test_write_then_read_round_trip + Hive::InstallMethod.write(channel: "script", version: "0.1.0", bin: "hv", package: "install.sh") + data = Hive::InstallMethod.read + assert_equal "script", data["channel"] + assert_equal "0.1.0", data["version"] + assert_equal "hv", data["bin"] + assert_equal "install.sh", data["package"] + assert File.exist?(Hive::InstallMethod.marker_path), "marker must be written to the XDG state dir" + end + + def test_write_channel_validates + err = assert_raises(ArgumentError) { Hive::InstallMethod.write(channel: "bogus") } + assert_match(/unknown channel/, err.message) + end + + def test_clear_removes_marker + Hive::InstallMethod.write(channel: "brew") + Hive::InstallMethod.clear + refute File.exist?(Hive::InstallMethod.marker_path) + assert_nil Hive::InstallMethod.read + end + + def test_read_returns_nil_on_corrupt_marker + FileUtils.mkdir_p(File.dirname(Hive::InstallMethod.marker_path)) + File.write(Hive::InstallMethod.marker_path, "not: [valid\n yaml") + assert_nil Hive::InstallMethod.read, "corrupt marker must be treated as absent (no guessing)" + assert_nil Hive::InstallMethod.channel + end + + def test_marker_lives_under_xdg_state_dir + assert_equal Hive::Paths.state_dir, File.dirname(Hive::InstallMethod.marker_path) + end +end \ No newline at end of file diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb new file mode 100644 index 000000000..a262b84fe --- /dev/null +++ b/test/unit/paths_test.rb @@ -0,0 +1,195 @@ +require "test_helper" +require "hive/paths" +require "hive/config" +require "hive/stages/base" + +REPO_ROOT = File.expand_path("../..", __dir__) + +# U2 — XDG Base Directory path migration. Verifies the resolvers honour the +# XDG_*_HOME overrides and the standard defaults, and that hive's global +# config / state files land in XDG dirs (not ~/Dev/hive) for a fresh install. +class PathsTest < Minitest::Test + include HiveTestHelper + + XDG_VARS = %w[XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME] + + def teardown + XDG_VARS.each { |v| ENV.delete(v) } + ENV.delete("HIVE_HOME") + end + + # Point HOME at a real temp dir so XDG default paths (which mkdir_p + # under) are creatable during the test. + def with_home + dir = Dir.mktmpdir("hive-home") + old = ENV["HOME"] + ENV["HOME"] = dir + yield dir + ensure + ENV["HOME"] = old + FileUtils.rm_rf(dir) if dir + end + + def test_paths_defaults_follow_xdg_layout + with_home do |h| + p = Hive::Paths + assert_equal File.join(h, ".config", "hive"), p.config_dir + assert_equal File.join(h, ".config", "hive", "config.yml"), p.config_path + assert_equal File.join(h, ".local", "share", "hive"), p.data_dir + assert_equal File.join(h, ".local", "state", "hive"), p.state_dir + assert_equal File.join(h, ".cache", "hive"), p.cache_dir + assert_equal File.join(h, ".local", "bin"), p.bin_dir + end + end + + def test_paths_honor_xdg_env_overrides + with_home do |_h| + ENV["XDG_CONFIG_HOME"] = "/cfg" + ENV["XDG_DATA_HOME"] = "/dat" + ENV["XDG_STATE_HOME"] = "/st" + ENV["XDG_CACHE_HOME"] = "/ck" + assert_equal File.join("/cfg", "hive"), Hive::Paths.config_dir + assert_equal File.join("/dat", "hive"), Hive::Paths.data_dir + assert_equal File.join("/st", "hive"), Hive::Paths.state_dir + assert_equal File.join("/ck", "hive"), Hive::Paths.cache_dir + end + end + + def test_paths_treat_blank_env_override_as_unset + with_home do |h| + ENV["XDG_CONFIG_HOME"] = "" + assert_equal File.join(h, ".config", "hive"), Hive::Paths.config_dir, + "a set-but-blank XDG_CONFIG_HOME must fall back to the default" + end + end + + def test_config_hive_home_resolves_to_xdg_state_when_hive_home_unset + with_home do |h| + ENV.delete("HIVE_HOME") + assert_equal File.join(h, ".local", "state", "hive"), Hive::Config.hive_home + end + end + + def test_config_hive_home_honors_explicit_hive_home + with_home do |_h| + ENV["HIVE_HOME"] = "/custom" + assert_equal "/custom", Hive::Config.hive_home + end + end + + def test_config_hive_home_treats_blank_hive_home_as_unset + with_home do |h| + ENV["HIVE_HOME"] = "" + assert_equal File.join(h, ".local", "state", "hive"), Hive::Config.hive_home, + "a set-but-blank HIVE_HOME must not yank the install back to a legacy home" + end + end + + # U2 test scenario 1: `XDG_CONFIG_HOME=/tmp/xdg` → global config lives + # under /tmp/xdg/hive/config.yml, never ~/Dev/hive. + def test_global_config_write_path_honors_xdg_config_home + with_home do |_h| + ENV["XDG_CONFIG_HOME"] = "/tmp/xdg" + ENV.delete("HIVE_HOME") + assert_equal File.join("/tmp/xdg", "hive", "config.yml"), Hive::Config.global_config_write_path + end + end + + def test_global_config_path_resolves_to_xdg_when_it_exists + with_home do |_h| + ENV.delete("HIVE_HOME") + with_tmp_dir do |dir| + ENV["XDG_CONFIG_HOME"] = dir + FileUtils.mkdir_p(Hive::Paths.config_dir) + File.write(Hive::Paths.config_path, { "registered_projects" => [] }.to_yaml) + assert_equal Hive::Paths.config_path, Hive::Config.global_config_path + end + end + end + + # U2 test scenario 2: no XDG config but legacy ~/Dev/hive/config.yml + # present → still loaded (back-compat), and a one-time notice is emitted. + def test_global_config_path_falls_back_to_legacy_path + with_home do |_h| + ENV.delete("HIVE_HOME") + # The legacy path is deterministic relative to a real temp HOME: + # ~/Dev/hive/config.yml under the temp home. + ENV["XDG_CONFIG_HOME"] = File.join(Dir.mktmpdir("xdg"), "nope") + legacy = File.expand_path("~/Dev/hive/config.yml") + FileUtils.mkdir_p(File.dirname(legacy)) + File.write(legacy, { "registered_projects" => [] }.to_yaml) + + refute File.exist?(Hive::Paths.config_path), "setup: XDG config must be absent" + assert_equal legacy, Hive::Config.global_config_path + assert Hive::Config.using_legacy_global_config?, "legacy fallback must be flagged" + end + end + + def test_using_legacy_global_config_false_when_hive_home_explicit + with_home do |_h| + ENV["HIVE_HOME"] = "/custom" + assert_equal false, Hive::Config.using_legacy_global_config?, + "explicit HIVE_HOME must never be treated as the legacy default" + end + end + + # U2 test scenario 3 (packaged simulation): templates/ schemas resolve + # from the XDG data dir when the source tree is "stripped". + def test_schema_dir_resolves_installed_data_dir_first + with_home do |_h| + ENV.delete("HIVE_HOME") + with_tmp_dir do |dir| + ENV["XDG_DATA_HOME"] = dir + installed = Hive::Paths.schemas_dir + FileUtils.mkdir_p(installed) + assert_equal installed, Hive::Schemas.schema_dir + end + end + end + + def test_schema_dir_falls_back_to_source_tree_when_data_dir_absent + with_home do |_h| + ENV.delete("HIVE_HOME") + ENV["XDG_DATA_HOME"] = Dir.mktmpdir("xdg") + refute File.directory?(Hive::Paths.schemas_dir), "setup: data dir must be absent" + assert_equal File.join(REPO_ROOT, "schemas"), Hive::Schemas.schema_dir + end + end + + def test_builtin_template_path_resolves_installed_dir_first + with_home do |_h| + ENV["XDG_DATA_HOME"] = Dir.mktmpdir("xdg") + installed = Hive::Paths.templates_dir + FileUtils.mkdir_p(installed) + File.write(File.join(installed, "probe.md.erb"), "installed") + assert_equal File.join(installed, "probe.md.erb"), + Hive::Stages::Base.builtin_template_path("probe.md.erb") + end + end + + def test_builtin_template_path_falls_back_to_source_tree + with_home do |_h| + ENV["XDG_DATA_HOME"] = Dir.mktmpdir("xdg") + refute File.directory?(Hive::Paths.templates_dir), "setup: data dir must be absent" + name = "fix_prompt.md.erb" + assert_equal File.join(REPO_ROOT, "templates", name), + Hive::Stages::Base.builtin_template_path(name) + end + end + + # End-to-end: registering a project under XDG (HIVE_HOME unset) writes to + # the XDG config path, not ~/Dev/hive. + def test_register_project_writes_to_xdg_config_dir + with_home do |_h| + ENV["HIVE_HOME"] = nil + with_tmp_dir do |dir| + ENV["XDG_CONFIG_HOME"] = dir + with_tmp_dir do |proj| + Hive::Config.register_project(name: "x", path: proj) + assert File.exist?(Hive::Paths.config_path), "global config must land in XDG config dir" + assert Hive::Config.registered_projects.any? { |e| e["name"] == "x" } + end + end + end + end +end \ No newline at end of file diff --git a/test/unit/service_test.rb b/test/unit/service_test.rb new file mode 100644 index 000000000..5958cdce6 --- /dev/null +++ b/test/unit/service_test.rb @@ -0,0 +1,126 @@ +require "test_helper" +require "hive/service" + +# U3 — daemon service registration. Verifies launchd/systemd-user unit +# generation, path resolution, and the enable+start command surface. +class ServiceTest < Minitest::Test + include HiveTestHelper + + def setup + @home = Dir.mktmpdir("hive-service-home") + Hive::Service.home_override = @home + end + + def teardown + Hive::Service.home_override = nil + FileUtils.rm_rf(@home) if @home + end + + def with_hive_bin(path) + old = ENV["HIVE_BIN"] + ENV["HIVE_BIN"] = path + yield + ensure + old ? ENV["HIVE_BIN"] = old : ENV.delete("HIVE_BIN") + end + + def test_unit_paths_follow_platform + # macOS branch + old = RUBY_PLATFORM + stub_platform("arm64-darwin24") do + assert_equal File.join(@home, "Library", "LaunchAgents", "local.hive-daemon.plist"), + Hive::Service.unit_path + assert_equal "local.hive-daemon", Hive::Service.unit_name + assert_equal [ "launchctl load -w #{Hive::Service.unit_path}" ], + Hive::Service.enable_start_commands + end + ensure + Hive::Service.home_override = @home + end + + def test_systemd_unit_path_and_enable_commands_on_linux + stub_platform("x86_64-linux") do + unit = File.join(@home, ".config", "systemd", "user", "hive-daemon.service") + assert_equal unit, Hive::Service.unit_path + assert_equal "hive-daemon", Hive::Service.unit_name + assert_equal( + [ "systemctl --user daemon-reload", + "systemctl --user enable --now hive-daemon.service" ], + Hive::Service.enable_start_commands + ) + end + end + + def test_binary_path_prefers_hive_bin_override + stub_platform("x86_64-linux") do + with_hive_bin("/opt/homebrew/bin/hive") do + assert_equal "/opt/homebrew/bin/hive", Hive::Service.binary_path + end + end + end + + def test_binary_path_falls_back_to_xdg_bin_dir + stub_platform("x86_64-linux") do + with_hive_bin(nil) do + assert_equal File.join(Hive::Paths.bin_dir, "hive"), Hive::Service.binary_path + end + end + end + + def test_generate_writes_systemd_unit_with_resolved_binary + stub_platform("x86_64-linux") do + with_hive_bin("/usr/local/bin/hive") do + path = Hive::Service.generate + assert File.exist?(path) + content = File.read(path) + assert_includes content, "[Unit]" + assert_includes content, "[Service]" + assert_includes content, "ExecStart=/usr/local/bin/hive daemon start" + assert_includes content, "StartLimitBurst=5" + assert_includes content, "[Install]" + assert_includes content, "WantedBy=default.target" + end + end + end + + def test_generate_writes_launchd_plist_on_macos + stub_platform("arm64-darwin24") do + with_hive_bin("/opt/homebrew/bin/hive") do + path = Hive::Service.generate + assert File.exist?(path) + content = File.read(path) + assert_includes content, "local.hive-daemon" + assert_includes content, "/opt/homebrew/bin/hive" + assert_includes content, "daemon" + assert_includes content, "start" + assert_includes content, "RunAtLoad" + end + end + end + + def test_generate_creates_units_only_behind_explicit_opt_in + # The unit is written by generate; enable/start is a separate prompt + # surface (enable_start_commands) that is never auto-executed here. + stub_platform("x86_64-linux") do + with_hive_bin("/usr/local/bin/hive") do + Hive::Service.generate + refute Hive::Service.enable_start_commands.empty?, + "enable/start commands must exist for the caller to run behind a prompt" + end + end + end + + private + + def stub_platform(platform) + Hive::Service.singleton_class.alias_method(:__orig_macos?, :macos?) + Hive::Service.define_singleton_method(:macos?) { platform.include?("darwin") } + yield + ensure + if Hive::Service.singleton_class.method_defined?(:__orig_macos?) + Hive::Service.define_singleton_method(:macos?, Hive::Service.singleton_class.instance_method(:__orig_macos?)) + Hive::Service.singleton_class.send(:remove_method, :__orig_macos?) + RUBY_PLATFORM + end + end +end \ No newline at end of file diff --git a/test/unit/uninstall_test.rb b/test/unit/uninstall_test.rb new file mode 100644 index 000000000..cff0f75f9 --- /dev/null +++ b/test/unit/uninstall_test.rb @@ -0,0 +1,156 @@ +require "test_helper" +require "hive/commands/uninstall" +require "hive/install_method" +require "hive/service" + +# U5 — `hive uninstall` removes binary registration + daemon unit, never +# deletes work by default, and never touches skills. +class UninstallTest < Minitest::Test + include HiveTestHelper + + def setup + @old_home = ENV["HOME"] + @home = Dir.mktmpdir("hive-un-home") + ENV["HOME"] = @home + @svc_home = Dir.mktmpdir("hive-un-svc") + Hive::Service.home_override = @svc_home + end + + def teardown + ENV["HOME"] = @old_home + Hive::Service.home_override = nil + Hive::InstallMethod.clear + FileUtils.rm_rf(@home) if @home + FileUtils.rm_rf(@svc_home) if @svc_home + end + + def fake_script_install(bin: "hive") + FileUtils.mkdir_p(Hive::Paths.bin_dir) + File.write(File.join(Hive::Paths.bin_dir, bin), "#!/bin/sh\n") + File.chmod(0o755, File.join(Hive::Paths.bin_dir, bin)) + Hive::InstallMethod.write(channel: "script", bin: bin) + end + + def fake_unit + Hive::Service.generate + Hive::Service.unit_path + end + + def fake_state_artifacts + FileUtils.mkdir_p(Hive::Paths.state_dir) + File.write(File.join(Hive::Paths.state_dir, "completed-work.txt"), "work") + Hive::Paths.state_dir + end + + def run_uninstall(purge: false, json: false) + Hive::Commands::Uninstall.new(purge: purge, json: json, runner: ->(_) { true }).call + end + + def test_removes_binary_and_daemon_unit_for_script_channel + bin = File.join(Hive::Paths.bin_dir, "hive") + fake_script_install + unit = fake_unit + assert File.exist?(bin) + assert File.exist?(unit) + + run_uninstall + + refute File.exist?(bin), "~/.local/bin/hive must be removed" + refute File.exist?(unit), "daemon OS unit must be removed" + assert_nil Hive::InstallMethod.read, "install-method marker must be cleared" + end + + def test_hv_fallback_binary_is_removed_when_marker_says_hv + bin = File.join(Hive::Paths.bin_dir, "hv") + fake_script_install(bin: "hv") + run_uninstall + refute File.exist?(bin), "~/.local/bin/hv must be removed when hive was installed as hv" + end + + def test_brew_managed_binary_is_handed_to_package_manager + Hive::InstallMethod.write(channel: "brew", package: "ivankuznetsov/hive/hive") + out = with_captured_uninstall_text + assert_match(/brew uninstall ivankuznetsov\/hive\/hive/, out, + "a brew-managed binary must be removed via brew, not deleted in place") + end + + def test_state_dir_and_work_artifacts_are_preserved_by_default + fake_script_install + state = fake_state_artifacts + run_uninstall + assert File.exist?(state), "state dir must be preserved by default" + assert File.exist?(File.join(state, "completed-work.txt")), + "completed-work artifacts must never be deleted by default" + end + + def test_purge_removes_config_but_preserves_state_and_work + fake_script_install + state = fake_state_artifacts + # Simulate installed shared assets + config. + FileUtils.mkdir_p(Hive::Paths.config_dir) + File.write(Hive::Paths.config_path, "registered_projects: []\n") + FileUtils.mkdir_p(Hive::Paths.data_dir) + FileUtils.mkdir_p(Hive::Paths.cache_dir) + + out = with_captured_uninstall(purge: true) + + refute File.directory?(Hive::Paths.config_dir), "--purge must remove XDG config dir" + refute File.directory?(Hive::Paths.data_dir), "--purge must remove XDG data (shared assets) dir" + refute File.directory?(Hive::Paths.cache_dir), "--purge must remove XDG cache dir" + assert File.directory?(state), "--purge must NEVER remove the state dir" + assert File.exist?(File.join(state, "completed-work.txt")), + "--purge must preserve completed-work artifacts" + assert_match(/preserved even under --purge/, out) + end + + def test_json_envelope_reports_removed_paths + fake_script_install + unit = fake_unit + doc = JSON.parse(with_captured_json) + assert_equal true, doc["ok"] + assert_equal "script", doc["channel"] + types = doc["removed"].map { |r| r["type"] } + assert_includes types, "binary" + assert_includes types, "service_unit" + assert_match(/Skills are NOT removed/, doc["note"]) + end + + def test_skills_are_never_touched + fake_script_install + # Skills ship via the agent marketplace only (U8), not hive's XDG data + # dir. Model a marketplace skills dir under the (overridden) HOME and + # assert `hive uninstall --purge` never removes it. + skills = File.join(@home, ".claude", "skills", "ce-code-review") + FileUtils.mkdir_p(skills) + File.write(File.join(skills, "SKILL.md"), "skill") + run_uninstall(purge: true) + assert File.exist?(skills), + "agent-marketplace skills must never be removed by hive uninstall" + end + + private + + def with_captured_uninstall_text + out, _err, = with_captured_io { run_uninstall } + out + end + + def with_captured_uninstall(purge:) + out, _err, = with_captured_io { run_uninstall(purge: purge) } + out + end + + def with_captured_json + out, _err, = with_captured_io { run_uninstall(json: true) } + out + end + + def with_captured_io + real = $stdout + $stdout = StringIO.new + yield + [ $stdout.string, "" ] + ensure + $stdout = real + end +end \ No newline at end of file diff --git a/test/unit/update_test.rb b/test/unit/update_test.rb new file mode 100644 index 000000000..d569e644e --- /dev/null +++ b/test/unit/update_test.rb @@ -0,0 +1,82 @@ +require "test_helper" +require "hive/commands/update" +require "hive/install_method" + +# U4 — `hive update` shells out to the detected channel's native updater and +# never swaps the binary in place. +class UpdateTest < Minitest::Test + include HiveTestHelper + + def setup + @old_bin = ENV["HIVE_BIN"] + @old_home = ENV["HOME"] + @home = Dir.mktmpdir("hive-upd-home") + ENV["HOME"] = @home + @ran = [] + end + + def teardown + ENV["HIVE_BIN"] = @old_bin + ENV["HOME"] = @old_home + FileUtils.rm_rf(@home) if @home + end + + def runner + ->(cmd) { @ran << cmd; true } + end + + def with_marker(channel, **fields) + Hive::InstallMethod.write(channel: channel, **fields) + yield + ensure + Hive::InstallMethod.clear + end + + def test_brew_channel_runs_brew_upgrade + with_marker("brew", package: "ivankuznetsov/hive/hive") do + Hive::Commands::Update.new(runner: runner).call + assert_equal [ "brew upgrade ivankuznetsov/hive/hive" ], @ran + end + end + + def test_aur_channel_runs_aur_helper_update + with_marker("aur", package: "hive-bin") do + Hive::Commands::Update.new(runner: runner).call + assert_equal [ "yay -Syu hive-bin" ], @ran + end + end + + def test_script_channel_reruns_one_liner + with_marker("script") do + Hive::Commands::Update.new(runner: runner).call + assert_equal 1, @ran.size + assert_match %r{curl -fsSL .*install\.sh \| bash}, @ran.first + end + end + + def test_missing_marker_raises_and_runs_nothing + err = assert_raises(Hive::Error) { Hive::Commands::Update.new(runner: runner).call } + assert_match(/not installed via a known channel/, err.message) + assert_empty @ran + end + + def test_explicit_channel_override_is_used + with_marker("script") do + Hive::Commands::Update.new(channel: "brew", runner: runner).call + assert_equal [ "brew upgrade ivankuznetsov/hive/hive" ], @ran, + "an explicit channel override must win over the marker" + end + end + + def test_failed_updater_raises_updater_error_with_software_exit + failing = ->(_cmd) { false } + with_marker("brew", package: "ivankuznetsov/hive/hive") do + err = assert_raises(Hive::UpdaterError) do + Hive::Commands::Update.new(runner: failing).call + end + assert_match(/updater failed/, err.message) + assert_equal Hive::ExitCodes::SOFTWARE, err.exit_code, + "a failed updater must surface as SOFTWARE (70), not silently" + end + end +end \ No newline at end of file diff --git a/tools/release/populate_checksums.rb b/tools/release/populate_checksums.rb new file mode 100644 index 000000000..554d206e1 --- /dev/null +++ b/tools/release/populate_checksums.rb @@ -0,0 +1,76 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# U6 — populate the brew formula + AUR PKGBUILD sha256 checksums from the +# release SHA256SUMS file (generated by .github/workflows/release.yml). +# +# The formula and PKGBUILD ship all-zero placeholder checksums so a source +# checkout stays `makepkg`/`brew`-parseable before the first release; this +# script replaces those placeholders with the real per-asset hashes, wiring +# the "verify SHA256SUMS" guarantee (U6) end-to-end. Run from the release job: +# +# ruby tools/release/populate_checksums.rb release-assets/SHA256SUMS + +PLACEHOLDER = ("0" * 64).freeze + +def die(message) + warn "populate_checksums: #{message}" + exit 1 +end + +def read_sums(path) + sums = {} + File.readlines(path).each do |line| + line = line.strip + next if line.empty? + + parts = line.split + sum = parts[0] + name = parts[-1] + die("unparsable SHA256SUMS line: #{line.inspect}") unless sum && name && sum != name + sums[name] = sum + end + sums +end + +def replace_in_order!(content, asset, sum) + content.sub!("sha256 \"#{PLACEHOLDER}\"", "sha256 \"#{sum}\"") || + die("no remaining placeholder for #{asset}") +end + +def update_formula(path, sums) + content = File.read(path) + # Placeholders appear in file order: darwin-arm64, darwin-x86_64, + # linux-aarch64, linux-x86_64. + %w[ + hive-darwin-arm64 + hive-darwin-x86_64 + hive-linux-aarch64 + hive-linux-x86_64 + ].each do |asset| + sum = sums[asset] || die("missing sha256 for #{asset} in SHA256SUMS") + replace_in_order!(content, asset, sum) + end + die("unreplaced sha256 placeholder remains in #{path}") if content.include?("sha256 \"#{PLACEHOLDER}\"") + File.write(path, content) + puts "populate_checksums: updated #{path}" +end + +def update_pkgbuild(path, sums) + content = File.read(path) + x86 = sums["hive-linux-x86_64"] || die("missing sha256 for hive-linux-x86_64") + aarch64 = sums["hive-linux-aarch64"] || die("missing sha256 for hive-linux-aarch64") + content.sub!("sha256sums=('#{PLACEHOLDER}')", "sha256sums=('#{x86}')") || + die("no x86_64 sha256sums placeholder in #{path}") + content.sub!("sha256sums_aarch64=('#{PLACEHOLDER}')", "sha256sums_aarch64=('#{aarch64}')") || + die("no aarch64 sha256sums placeholder in #{path}") + die("unreplaced sha256sums placeholder remains in #{path}") if content.include?(PLACEHOLDER) + File.write(path, content) + puts "populate_checksums: updated #{path}" +end + +sums_path = ARGV[0] || die("usage: populate_checksums.rb SHA256SUMS") +root = File.expand_path("../..", __dir__) +sums = read_sums(sums_path) +update_formula(File.join(root, "packaging", "homebrew", "hive.rb"), sums) +update_pkgbuild(File.join(root, "packaging", "aur", "PKGBUILD"), sums) diff --git a/tools/tebako/build.rb b/tools/tebako/build.rb new file mode 100644 index 000000000..0d0c8e8d0 --- /dev/null +++ b/tools/tebako/build.rb @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# U1 — Tebako build driver for hive. +# +# Produces a single self-contained, runtime-free `hive` binary (vendored +# Ruby + gems) from the source checkout, then SHA-256-checksums it. Run +# natively on the target OS/arch (no cross-compilation — the release +# workflow launches one job per tier-1 target on GitHub-hosted runners). +# +# Usage: +# ruby tools/tebako/build.rb # -> dist/hive, dist/hive.sha256 +# +# Prerequisites: +# - `gem install tebako` (bundles a compatible compile toolchain) +# - `bundle install` (locks the gem versions Tebako vendors) +# +# After the build, the binary must pass the U1 smoke checks: +# - `hive --version` prints Hive::VERSION with no system Ruby on PATH +# - `hive status` / `hive run --help` resolve templates/schemas +# - the bubbletea/lipgloss FFI libs load (run `hive tui` headlessly or +# exercise the TUI's prompt path) — the single highest-risk area. + +require "fileutils" +require "digest" +require "open3" + +EXIT_SUCCESS = 0 +EXIT_BUILD_FAILED = 1 + +def sh(*cmd) + stdout, stderr, status = Open3.capture3(*cmd) + unless status.success? + warn "tebako: command failed: #{cmd.inspect}\n#{stderr}" + exit EXIT_BUILD_FAILED + end + stdout +end + +root = File.expand_path("../..", __dir__) +dist = File.join(root, "dist") +FileUtils.rm_rf(dist) +FileUtils.mkdir_p(dist) + +# Tebako press: single-executable build. Flags reflect the Tebako CLI; if a +# flag drifts from your installed tebako version, run `tebako help press`. +build_args = [ + "tebako", "press", + "--dir", root, + "--entry-point", "bin/hive", + "--target", "hive", + "--options-file", File.join(root, "tools", "tebako", "tebako.yml") +] +puts "hive: tebako press — building vendored-Ruby binary (may take several minutes)" +sh(*build_args) + +# Tebako writes the artifact next to the entry point by default. +artifact = File.join(File.dirname(File.join(root, "bin", "hive")), "hive") +FileUtils.mkdir_p(dist) +dst = File.join(dist, "hive") +FileUtils.cp(artifact, dst) + +sha = Digest::SHA256.file(dst).hexdigest +File.write(File.join(dist, "hive.sha256"), "#{sha} #{File.basename(dst)}\n") +puts "hive: built #{dst} (#{sha})" + +puts "hive: smoke-checking binary" +# NOTE: `hive --version` intentionally does not require Ruby on PATH (the +# binary bundles it); the release workflow runs this in a clean container +# without system ruby to prove runtime-freedom. +version = sh(dst, "--version").strip +expected = begin + require File.join(root, "lib", "hive") + Hive::VERSION +rescue StandardError + ENV["HIVE_VERSION"] || "0.1.0" +end +if version != expected + warn "hive: built binary reports #{version.inspect}, expected #{expected.inspect}" + exit EXIT_BUILD_FAILED +end + +puts "hive: build complete — dist/hive + dist/hive.sha256" \ No newline at end of file diff --git a/tools/tebako/tebako.yml b/tools/tebako/tebako.yml new file mode 100644 index 000000000..289465cdd --- /dev/null +++ b/tools/tebako/tebako.yml @@ -0,0 +1,39 @@ +# Tebako build config for hive (U1). +# +# Tebako packages a Ruby application + its gem dependencies into a single +# self-contained, runtime-free executable (a vendored-Ruby binary). This is +# the delivery artifact every install channel (U6) consumes. +# +# The two Charm FFI gems (bubbletea 0.1.4, lipgloss) bind native Go shared +# libraries. Before a release, verify Tebako bundles those `.dylib`/`.so` +# libraries and that the packaged binary discovers them at runtime (the +# single highest-risk part of U1 — see the plan's Risks). +# +# Reference build (see tools/tebako/build.rb): +# tebako press \ +# --dir . \ +# --root-path /app \ +# --target hive \ +# --entry-point bin/hive \ +# --options-file tebako.yml + +# Entry point relative to the build root. +entrypoint: bin/hive +# Name of the produced single executable. +target: hive +# Dependencies vendored into the binary (the version pins mirror Gemfile / +# hive.gemspec; keep in lockstep). +gems: + - thor: "~> 1.3" + - telegram-bot-ruby: "~> 2.7" + - bubbletea: "= 0.1.4" + - lipgloss: "~> 0.2.2" +# Shared data assets shipped into the binary's data dir so template/schema +# resolution works without a source tree (U2/U1). Tebako mounts these at +# $HIVE_INSTALL_PATH on the packaged filesystem; Hive::Paths resolves the XDG +# data dir and Stages::Base / Schemas fall back to them at build time. +assets: + - templates/**/* -> templates/ + - schemas/**/* -> schemas/ +# Ruby version bundled with the binary. +ruby_version: "3.4" \ No newline at end of file diff --git a/wiki/index.md b/wiki/index.md index 2b728a8f3..ee33bfa41 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -63,6 +63,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig - [[modules/task_action]] — `wiki/modules/task_action.md` - [[modules/task_resolver]] — `wiki/modules/task_resolver.md` - [[modules/workflows]] — `wiki/modules/workflows.md` +- [[modules/installable-package]] — `wiki/modules/installable-package.md` - [[modules/worktree]] — `wiki/modules/worktree.md` - [[operating]] — `wiki/operating.md` - [[stages/brainstorm]] — `wiki/stages/brainstorm.md` diff --git a/wiki/log.md b/wiki/log.md index 641bec70a..b1403ad9b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1607,3 +1607,17 @@ 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-08-13T00:00:00Z] installable package + XDG layout (U1–U9) + +**Action:** Made `hive` installable as a self-contained, runtime-free binary. +Added XDG Base Directory path resolution (`Hive::Paths`, `Hive::Config` +global-config → `~/.config/hive/config.yml` with legacy `~/Dev/hive` fallback), +the install-method channel marker, `hive update`/`hive uninstall` (+`--purge`), +`hive init` daemon OS-unit registration with an autostart prompt, the Tebako +release pipeline + Homebrew/AUR/one-liner channels, and the marketplace-only +skills contract. + +**Refreshed pages:** +- `wiki/modules/installable-package.md` — new module map for install/XDG. +- `wiki/index.md` — added the new module page. diff --git a/wiki/modules/installable-package.md b/wiki/modules/installable-package.md new file mode 100644 index 000000000..440d93aa0 --- /dev/null +++ b/wiki/modules/installable-package.md @@ -0,0 +1,63 @@ +--- +title: Installable package & XDG layout +type: module +source: lib/hive/{paths,install_method,service}.rb, packaging/, .github/workflows/{release,acceptance-install}.yml, INSTALL.md +created: 2026-08-13 +tags: [xdg, packaging, install, release] +--- + +# Installable package & XDG layout + +Made `hive` installable end-to-end as a self-contained, runtime-free binary +(rather than a source checkout under `~/Dev/hive`). This page is the +engineering map; the user-facing docs are `packaging/README.md`, +`INSTALL.md`, and `docs/release/acceptance.md`. + +## Key modules + +- [[modules/paths]] — `Hive::Paths`: XDG Base Directory resolution + (`~/.config/hive`, `~/.local/share/hive`, `~/.local/state/hive`, + `~/.cache/hive`, binary at `~/.local/bin`). `HIVE_HOME` (explicit) still + overrides; a set-but-empty `HIVE_HOME` counts as unset so CI can't yank an + install back to the legacy home. +- `Hive::Config` — global config now resolves to `~/.config/hive/config.yml` + (write target); the read path falls back to legacy `~/Dev/hive/config.yml` + during the transition with a one-time notice. `hive_home` defaults to the + XDG state dir when `HIVE_HOME` is unset. +- [[modules/install_method]] — `Hive::InstallMethod`: the channel marker at + `~/.local/state/hive/install-method` (`{channel, package, version, bin}`). + Absent/corrupt = unknown channel; callers never guess. +- [[modules/service]] — `Hive::Service`: generates the launchd + (macOS) / systemd-user (Linux) daemon unit; enable+start stays behind the + autostart prompt (never silent). + +## Commands + +- `hive update` — shells out to the detected channel's native updater; never + swaps the binary in place (R14). +- `hive uninstall [--purge]` — removes binary + daemon unit + marker; hands + brew/AUR binaries to the package manager; `--purge` removes XDG config/data/ + cache but ALWAYS preserves `~/.local/state/hive` and project `.hive-state/` + work; never touches skills (R15). + +## Channels (U6) + +Homebrew formula (`packaging/homebrew/hive.rb`), AUR `hive-bin` +(`packaging/aur/PKGBUILD`), one-liner (`packaging/install.sh`) — all consume +the same tag-driven GitHub Release artifacts + `SHA256SUMS` from U1 +(`.github/workflows/release.yml`, Tebako vendored-Ruby build). + +## Skills separation (U8) + +Agent skills ship ONLY through each agent's marketplace (`docs/skills-package.md`). +The core binary, `hive init`, and `hive uninstall` never install/remove skills. + +## Decisions + +- XDG layout over the `~/Dev/hive` source-tree default; `HIVE_HOME` and the + legacy config path are back-compat only. +- Tebako chosen over ruby-packer (maintained, Ruby 3.4 support). +- Autostart defaults to **no action** (never silent) — separate prompt from + the per-project daemon-enrollment prompt. +- `.hive-state/` remains the canonical per-project state dir (the brainstorm's + `.hive/` was treated as a loose reference; no rename). \ No newline at end of file