diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..161e5a3cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +# Release pipeline (packaging plan U1). +# +# Every push of a `v*` tag builds checksummed, self-contained release +# tarballs for the tier-1/tier-2 matrix and attaches them (plus a combined +# SHA256SUMS and the verbatim install.sh) to a GitHub Release. The Release +# is the canonical artifact source for all four install channels: +# Homebrew tap, AUR hive-bin, the curl|bash one-liner, and the install.md +# prompt. +# +# Build matrix (oldest-glibc rule: Linux artifacts are built on the +# OLDEST supported tier-1 runner, ubuntu-22.04, so glibc compatibility +# covers Ubuntu 22.04+; Arch's forward-only glibc is fine): +# macos-arm64 (macos-14, tier-1) +# macos-x86_64 (macos-13, tier-2) +# linux-x86_64-gnu (ubuntu-22.04, tier-1) +# +# Artifact format decision (see packaging/README.md): portable directory +# bundle (vendored Ruby + gems + shim), NOT a single-file packed binary — +# bubbletea/lipgloss FFI static libs made packers a proven hazard. + +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + HIVE_REPO: ivankuznetsov/hive + +jobs: + build: + name: build ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + platform: macos-arm64 + ruby-build: brew + - os: macos-13 + platform: macos-x86_64 + ruby-build: brew + - os: ubuntu-22.04 + platform: linux-x86_64-gnu + ruby-build: git + steps: + - uses: actions/checkout@v4 + + - name: Install ruby-build (brew) + if: matrix.ruby-build == 'brew' + run: brew install ruby-build openssl@3 libyaml readline + + - name: Install ruby-build (git) + build deps + if: matrix.ruby-build == 'git' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + autoconf patch build-essential rustc libssl-dev libyaml-dev \ + libreadline6-dev zlib1g-dev libgmp-dev libffi-dev + git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build + echo "/tmp/ruby-build/bin" >> "$GITHUB_PATH" + + - name: Build bundle + run: packaging/build-bundle.sh dist + + - name: Rename checksum fragment per platform + run: | + cd dist + for f in *.sha256; do + mv "$f" "$f.${{ matrix.platform }}.fragment" + done + + - uses: actions/upload-artifact@v4 + with: + name: hive-${{ matrix.platform }} + path: | + dist/*.tar.gz + dist/*.fragment + if-no-files-found: error + + release: + name: publish GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Assemble SHA256SUMS + run: | + cd dist + cat *.fragment | sort -k2 > SHA256SUMS + rm -f *.fragment + cat SHA256SUMS + + - name: Attach install.sh verbatim + run: cp packaging/install.sh dist/install.sh + + - name: Create Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --repo "$HIVE_REPO" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + dist/* diff --git a/README.md b/README.md index 1fd7a0ff1..0cc9f2073 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,25 @@ 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 bundle (vendored Ruby — **no system Ruby required**). Four install channels, all fed from tagged GitHub Releases: + +| Channel | Command | Platforms | +|---|---|---| +| Homebrew tap | `brew tap /hive https://github.com//homebrew-hive && brew install /hive/hive` | macOS arm64 (tier-1), x86_64 (tier-2) | +| AUR | `yay -S hive-bin` | Arch Linux (tier-1) | +| One-liner | `curl -fsSL https://github.com/ivankuznetsov/hive/releases/latest/download/install.sh \| bash` | All tier-1: macOS, Ubuntu 22.04+, Debian 12+, Fedora 40+; WSL2 (tier-2) | +| Agent prompt | paste [install.md](install.md) into Claude Code / Codex / Pi | Picks the right channel for you | + +Tier-3 (unsupported): Alpine/musl, NixOS, BSDs, Windows-native. + +**Required tools:** `git`, `bash` (`hive doctor` exits 66 if either is missing). **Recommended:** an authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, `gh`, `jq`, `tmux` — the installer and doctor report these with per-OS hints but never auto-install them. See [docs/getting-started.md#prerequisites](docs/getting-started.md#prerequisites). + +**Name collision:** if another `hive` command exists on PATH (e.g. Apache Hive), the one-liner installs the launcher as `hv` instead — use `hv` wherever docs say `hive`. Brew/AUR always install as `hive`. + +**Updating & removing:** `hive update` upgrades through whichever channel installed it (brew → `brew upgrade hive`, AUR → `yay -Syu hive-bin`, one-liner → re-runs the installer); it never swaps binaries itself. `hive uninstall` removes the daemon service registration and prints the exact package-manager removal line; your projects' `.hive-state` work is only touched with explicit per-project opt-in. Skills for agent CLIs ship separately via each agent's marketplace ([skills/](skills/README.md)) and are never installed or removed by the core installer. + +
+Dev checkout (from source) ```bash git clone https://github.com/ivankuznetsov/hive ~/Dev/hive @@ -96,7 +114,10 @@ mkdir -p ~/.local/bin ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive ``` -If `~/.local/bin` is not on `PATH`, put the symlink in a directory that is. The `-sf` form overwrites an existing `hive` at the target path; run `command -v hive` first if you already have one installed and don't want to clobber it. Verify the install with `hive --version` and `hive doctor`. +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`. Dev checkouts honor `$HIVE_HOME`: set it to the checkout to keep global config/state inside the tree. + +
+ ## Power-User / Scripting CLI diff --git a/install.md b/install.md new file mode 100644 index 000000000..f33e4be5b --- /dev/null +++ b/install.md @@ -0,0 +1,73 @@ +# Install hive — paste this prompt into your agent + +Give this file (or its text) to any coding agent — Claude Code, Codex, Pi — +and it will install hive for you, verify the install, and offer to +bootstrap your first project. + +--- + +You are installing **hive**, a folder-as-agent task-orchestration CLI, on +this machine. Follow these steps exactly and report progress at each one. +Never delete user work; if anything fails, stop and report. + +## 1. Detect platform + +Run `uname -s` and `uname -m`. Classify: + +- macOS arm64 → channel: **Homebrew** (fall back to one-liner if brew is absent) +- macOS x86_64 → channel: **Homebrew** (tier-2) +- Linux x86_64 with glibc (`ldd` exists) → channel: **AUR helper** (`yay`/`paru`) + if present, else the **one-liner** +- Anything else (Alpine/musl, NixOS, BSD, Windows) → **stop**: unsupported + (tier-3). Report the platform and exit. + +## 2. Check dependencies + +Required (hard-fail with the platform's install command if missing): +`git`, `bash`. Recommended (report with hints, never auto-install): +`claude`, `gh`, `jq`, `tmux`. + +Per-OS hints: macOS → `brew install `; Debian/Ubuntu → +`sudo apt-get install -y `; Arch → `sudo pacman -S `; +`claude` everywhere: `npm install -g @anthropic-ai/claude-code`. + +## 3. Install via the chosen channel + +- **Homebrew:** + `brew tap /hive https://github.com//homebrew-hive && brew install /hive/hive` +- **AUR (Arch, helper present):** + `yay -S hive-bin` (or `paru -S hive-bin`). Package name is `hive-bin`; + it provides the `hive` command. +- **One-liner (any tier-1 OS):** + `curl -fsSL https://github.com/ivankuznetsov/hive/releases/latest/download/install.sh | bash` + +If the installer reports a name collision with an existing `hive` command +(e.g. Apache Hive), it installs as `hv` instead — use `hv` everywhere +below and tell the user prominently. + +## 4. Verify + +Run `hive --version` (or `hv --version`). It must print a semver. Then run +`hive doctor` and report any `missing` rows with their install hints. + +## 5. Offer project bootstrap + +Ask the user: "Run `hive init` in the current directory?" Only if they +confirm, run `hive init` and report its summary. If the current directory +is not a git repository, say so and skip. + +## 6. Skills (optional, marketplace only) + +hive's agent skills ship separately through each agent's marketplace — +NEVER install skills by copying files. If the host agent supports a real +marketplace mechanism (e.g. Claude Code plugin marketplaces), install the +`hive-skills` package from the hive marketplace. If there is no +marketplace mechanism (copy-paste-only agents), skip this step and point +the user at `skills/README.md` in the repo. + +## 7. Report + +Summarize: platform, channel used, installed version, `hive doctor` +findings, whether init ran, whether skills were installed, and any +follow-up commands the user should run (e.g. adding `~/.local/bin` to +PATH). diff --git a/lib/hive/channel.rb b/lib/hive/channel.rb new file mode 100644 index 000000000..f7742ddad --- /dev/null +++ b/lib/hive/channel.rb @@ -0,0 +1,123 @@ +module Hive + # Install-channel detection shared by `hive update` (U7) and + # `hive uninstall` (U8) of the packaging plan. Hive never swaps its own + # binary — it identifies WHICH channel installed it and shells out to + # that channel's native updater / remover. + # + # Three managed channels: + # :brew — binary lives under a Homebrew prefix (Cellar / homebrew path) + # :pacman — pacman owns the binary file (pacman -Qo) + # :script — binary lives inside an install.sh bundle (marker file + # `.hive-bundle` next to bin/, written by packaging/install.sh) + # + # :unknown means "manual install / dev checkout" — update prints manual + # instructions; uninstall does best-effort local cleanup only. + module Channel + class AmbiguousChannel < Hive::Error + def exit_code + Hive::ExitCodes::USAGE + end + end + + DEFAULT_REPO = "ivankuznetsov/hive".freeze + BUNDLE_MARKER = ".hive-bundle".freeze + + module_function + + # Detect the channel that manages `bin_path`. Raises AmbiguousChannel + # when more than one signal matches (e.g. a brew Cellar path that + # pacman also claims) — update refuses to guess. + # + # `runner:` executes argv and returns [stdout, stderr, status] + # (injectable for tests). `home:` overrides ~ for bundle detection. + def detect(bin_path:, runner: nil, home: nil) + runner ||= default_runner + path = File.expand_path(bin_path) + + matches = [] + matches << :brew if brew_managed?(path) + matches << :pacman if pacman_managed?(path, runner) + matches << :script if script_managed?(path, home) + + if matches.size > 1 + raise AmbiguousChannel, + "hive install channel is ambiguous (#{matches.join(' + ')}); " \ + "refusing to guess. Resolve the overlap and retry." + end + + matches.first || :unknown + end + + def brew_managed?(path) + lowered = path.downcase + lowered.include?("/cellar/") || lowered.include?("/homebrew/") + end + + def pacman_managed?(path, runner) + _out, _err, status = runner.call(["pacman", "-Qo", path]) + status.success? + rescue StandardError + false + end + + def script_managed?(path, home = nil) + bundle_root = script_bundle_root(path, home) + !bundle_root.nil? + end + + # The bundle directory for a binary path, or nil when the binary is + # not inside an install.sh bundle. The marker file (`.hive-bundle`, + # written by packaging/install.sh) is authoritative; the + # ~/.local/opt/hive path shape is the documented fallback. + def script_bundle_root(path, home = nil) + dir = File.dirname(path) + marker = File.join(dir, "..", BUNDLE_MARKER) + return File.dirname(File.expand_path(marker)) if File.exist?(marker) + + base = home || File.expand_path("~") + local_opt = File.join(base, ".local", "opt", "hive") + expanded_dir = File.expand_path(dir) + return expanded_dir if expanded_dir == File.expand_path(local_opt) || + expanded_dir.start_with?(File.expand_path(local_opt) + File::SEPARATOR) + + nil + end + + # The native upgrade command for a channel, or nil when the channel + # can only be instructed manually (pacman without yay — sudo must + # never run unprompted from inside hive). + def upgrade_command(channel, runner: nil, repo: nil) + case channel + when :brew + %w[brew upgrade hive] + when :pacman + if command_available?("yay", runner) + %w[yay -Syu hive-bin] + end + when :script + script_install_command(repo: repo) + end + end + + # Re-run the pinned installer for the script channel. Never swaps the + # binary itself — the installer downloads, verifies and relinks. + def script_install_command(repo: nil) + repo ||= ENV["HIVE_REPO"] || DEFAULT_REPO + url = "https://github.com/#{repo}/releases/latest/download/install.sh" + [ "bash", "-c", "curl -fsSL #{url} | bash" ] + end + + def command_available?(name, runner = nil) + runner ||= default_runner + _out, _err, status = runner.call(["sh", "-c", "command -v #{name} >/dev/null 2>&1"]) + status.success? + rescue StandardError + false + end + + def default_runner + require "open3" + ->(argv) { Open3.capture3(*argv) } + end + end +end diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index 9bc60cb8c..0f4fbcd84 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -120,7 +120,9 @@ module Hive Hive::Commands::Prune.new(dry_run: options[:dry_run], json: options[:json]).call end - desc "doctor", "Verify each stage's configured skill is installed for its agent" + desc "doctor", "Verify stage skills AND OS-level dependencies for this machine" + option :strict, type: :boolean, default: false, + desc: "exit 67 when recommended tools (claude/gh/jq/tmux) are missing" long_desc <<~DESC Walks the brainstorm and plan stage configs and asks the configured agent profile (claude / codex / pi) to probe whether @@ -152,8 +154,12 @@ module Hive package_root). Each row's `message` field is the authoritative install hint. - Exit codes: 0 all checks present or N/A; 65 at least one missing - skill; 78 config error. + Exit codes: + 0 all checks present or N/A (recommended-tool hints may still print) + 65 at least one missing skill / version too old + 66 a REQUIRED dependency (git, bash) is missing + 67 --strict and at least one recommended tool missing + 78 config error Examples: @@ -166,7 +172,8 @@ module Hive exit Hive::Commands::Doctor.new( config: cfg, project_root: Dir.pwd, - json: options[:json] + json: options[:json], + strict: options[:strict] ).call end @@ -494,6 +501,10 @@ module Hive --all = every registered project; --json emits hive-daemon-enroll.v1. disable PROJECT|--all [--json] Set daemon.enabled: false there. + enable-service Register + start the daemon as an + OS service (launchd agent on macOS, + systemd --user unit on Linux). + disable-service Unload/disable and remove the unit. The daemon polls `hive status --json` periodically and dispatches workflow verbs (`hive plan` / `develop` / `review` / `pr`) on tasks @@ -554,6 +565,59 @@ module Hive ).call end + desc "update", "Upgrade hive through the channel that installed it (brew / AUR / install.sh)" + long_desc <<~DESC + Detects HOW hive was installed (Homebrew Cellar path, pacman ownership + via `pacman -Qo`, or the install.sh bundle marker under + ~/.local/opt/hive) and shells out to that channel's native updater. + hive never swaps its own binary: + + brew → brew upgrade hive + pacman → yay -Syu hive-bin (or a printed sudo pacman -Syu hint) + script → re-runs the pinned install.sh one-liner (latest release) + unknown → manual instructions; exit 1 + + Ambiguous detection (two channels claim the same binary) refuses to + guess and exits non-zero. + + With --dry-run, prints what would run without executing it. + DESC + option :dry_run, type: :boolean, default: false, desc: "print the upgrade command without running it" + def update + require "hive/commands/update" + exit Hive::Commands::Update.new( + dry_run: options[:dry_run] + ).call + end + + desc "uninstall", "Remove daemon registration + (script-channel) bundle; project state only via explicit opt-in" + long_desc <<~DESC + Clean removal that can never destroy user work: + + 1. Unloads/disables and removes the daemon service unit (launchd / + systemd --user). + 2. Channel layer: brew/pacman manage their own binaries — the exact + native removal line is printed for you. The install.sh script + channel removes its own bundle + symlinks directly. + 3. Project state (.hive-state worktree + hive/state branch) is + removed ONLY with explicit per-project opt-in at an interactive + prompt. Non-TTY runs — even with --purge — NEVER delete project + state. Skills installed into agent CLIs are never touched. + + --purge makes the binary/registration layer non-interactive; it does + not widen what may be deleted. + DESC + option :purge, type: :boolean, default: false, + desc: "non-interactive removal of binary/registration layers (never deletes project state)" + option :cleanup_projects, type: :array, desc: "explicit opt-in list of project names whose hive state should be removed" + def uninstall + require "hive/commands/uninstall" + exit Hive::Commands::Uninstall.new( + purge: options[:purge], + cleanup_projects: options[:cleanup_projects] + ).call + end + no_commands do # Emit a hive-daemon-enroll ErrorPayload to stdout when --json is # set, then raise so the bin/hive top-level rescue maps to the diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index f88bca6fd..109360560 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -27,7 +27,7 @@ module Hive class Daemon include Hive::Schemas::EnvelopeEmitter - VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable].freeze + VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable enable-service disable-service].freeze # USAGE-class error specific to enable/disable. Carries an # error_kind drawn from Hive::Schemas::EnrollErrorKind so the @@ -68,22 +68,67 @@ module Hive when "reload" then reload_daemon when "tail" then tail_daemon when "enable", "disable" then call_with_envelope { do_call } + when "enable-service" then enable_service + when "disable-service" then disable_service end end + # U6: register (and optionally enable+start) the daemon as an OS + # service. `enable-service` is the follow-up verb for non-TTY inits + # that registered without enabling. Delegates to Hive::Service. + def enable_service + require "hive/service" + path = Hive::Service.install!(enable_and_start: true, output: method(:warn)) + raise Hive::Error, "no service backend on this platform" if path.nil? + end + + def disable_service + require "hive/service" + Hive::Service.remove!(output: method(:warn)) + end + + # When a service unit is registered, start/stop/status delegate to + # the OS service manager instead of the PID-file protocol. Dev + # checkouts (no unit) and test runs (HIVE_SKIP_SERVICE_REGISTRATION) + # keep the historical PID-file mode. + def service_managed? + require "hive/service" + !Hive::Service.skipped? && Hive::Service.installed? + rescue StandardError + false + end + + # PID/log locations follow the XDG layout (packaging plan U2): + # XDG mode → ~/.local/state/hive/daemon.pid + logs/daemon.log + # dev-checkout mode ($HIVE_HOME set) → $HIVE_HOME/.daemon.pid + # + $HIVE_HOME/logs/daemon.log so a source checkout stays + # fully self-contained. def pid_file - @pid_file ||= File.join(@hive_home, ".daemon.pid") + @pid_file ||= if Hive::Config.legacy_mode? + File.join(@hive_home, ".daemon.pid") + else + File.join(Hive::Config.state_home, "daemon.pid") + end end def log_file - @log_file ||= File.join(@hive_home, "logs", "daemon.log") + @log_file ||= if Hive::Config.legacy_mode? + File.join(@hive_home, "logs", "daemon.log") + else + File.join(Hive::Config.state_home, "logs", "daemon.log") + end end private def start_daemon warn_unsupported_json_flag if @json - FileUtils.mkdir_p(@hive_home) + if service_managed? + Hive::Service.start! + warn "hive: daemon started via OS service" + return + end + FileUtils.mkdir_p(File.dirname(pid_file)) FileUtils.mkdir_p(File.dirname(log_file)) # Single-instance check: if a live daemon already owns the PID @@ -169,6 +214,11 @@ module Hive end def stop_daemon + if service_managed? + Hive::Service.stop! + warn "hive: daemon stopped via OS service" + return + end unless File.exist?(pid_file) if @json puts JSON.generate(stop_envelope(running: false, was_running: false)) @@ -261,6 +311,28 @@ module Hive end def status_daemon + if service_managed? + running = Hive::Service.running? + if @json + puts JSON.generate( + "schema" => "hive-daemon-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), + "ok" => true, + "running" => running, + "pid" => nil, + "uptime_sec" => nil, + "pid_file" => pid_file, + "log_file" => log_file + ) + elsif running + puts "hive daemon: running (service-managed)" + else + puts "hive daemon: not running" + end + raise Hive::Error, "daemon not running" unless running + return + end + running = false pid = nil uptime_sec = nil diff --git a/lib/hive/commands/doctor.rb b/lib/hive/commands/doctor.rb index 1b87c603c..c99b12bd0 100644 --- a/lib/hive/commands/doctor.rb +++ b/lib/hive/commands/doctor.rb @@ -36,6 +36,16 @@ module Hive EXIT_MISSING_SKILL = 65 EXIT_CONFIG_ERROR = 78 + # Install-awareness (packaging plan U10): doctor also reports OS-level + # dependencies. REQUIRED_TOOLS gate hive itself — a miss exits 66. + # RECOMMENDED_TOOLS are reported with actionable per-OS hints but do + # NOT fail the run unless --strict is passed (then they exit 67). + EXIT_MISSING_REQUIRED = 66 + EXIT_MISSING_RECOMMENDED = 67 + + REQUIRED_TOOLS = %w[git bash].freeze + RECOMMENDED_TOOLS = %w[claude gh jq tmux].freeze + STAGES = %w[brainstorm plan].freeze # Exposes the row set after `#call` has run. Lets in-process @@ -44,23 +54,36 @@ module Hive # JSON encoder. Returns `nil` before `#call` has populated it. attr_reader :rows - def initialize(config:, project_root:, json: false, output: $stdout) + def initialize(config:, project_root:, json: false, output: $stdout, + strict: false, probe_results: nil) @config = config @project_root = project_root @json = json @output = output + @strict = strict + # Injectable command-availability probe (Hash) for + # tests; production shells out to `command -v`. + @probe_results = probe_results @rows = nil end def call - @rows = check_tmux + check_stages + check_reviewers + @rows = check_dependencies + check_tmux + check_stages + check_reviewers if @json @output.puts JSON.generate(envelope(@rows)) else render_table(@rows) end - @rows.any? { |r| failing_status?(r[:status]) } ? EXIT_MISSING_SKILL : EXIT_SUCCESS + if @rows.any? { |r| r[:status] == "missing_required" } + EXIT_MISSING_REQUIRED + elsif @rows.any? { |r| failing_status?(r[:status]) } + EXIT_MISSING_SKILL + elsif @strict && @rows.any? { |r| r[:status] == "missing_recommended" } + EXIT_MISSING_RECOMMENDED + else + EXIT_SUCCESS + end rescue Hive::ConfigError, KeyError, ArgumentError => e if @json @output.puts JSON.generate(error: e.message) @@ -76,6 +99,88 @@ module Hive status == "missing" || status == "version_too_old" end + # ── install-awareness (U10) ────────────────────────────────────── + + # Required tools (git, bash) gate every hive operation: a miss is a + # hard failure (exit 66). Recommended tools (claude, gh, jq, tmux) + # degrade specific features; each carries an actionable per-OS hint + # and never fails the run unless --strict was passed. + def check_dependencies + rows = REQUIRED_TOOLS.map { |tool| dependency_row(tool, required: true) } + rows + RECOMMENDED_TOOLS.map { |tool| dependency_row(tool, required: false) } + end + + def dependency_row(tool, required:) + present = tool_available?(tool) + if present + status = "present" + message = "#{tool} found on PATH" + elsif required + status = "missing_required" + message = "#{tool} is REQUIRED to run hive — #{install_hint(tool)}" + else + status = "missing_recommended" + message = "#{tool} recommended — some features will not work. #{install_hint(tool)}" + end + + { + kind: "dependency", + stage: "install", + label: "dependency/#{tool}", + agent: tool, + configured_skill: nil, + skill: tool, + required: required, + status: status, + message: message + } + end + + def tool_available?(tool) + return @probe_results[tool] if @probe_results + + require "open3" + _out, _err, status = Open3.capture3("sh", "-c", "command -v #{tool} >/dev/null 2>&1") + status.success? + rescue StandardError + false + end + + # Actionable per-OS install hint. Tier table mirrors README: + # macOS (arm64/x86_64), Ubuntu/Debian, Arch; everything else falls + # back to the generic form. + def install_hint(tool) + pkg = case [ tool, os_family ] + when [ "git", :macos ] then "brew install git" + when [ "git", :debian ] then "sudo apt install git" + when [ "git", :arch ] then "sudo pacman -S git" + when [ "claude", :macos ] then "npm install -g @anthropic-ai/claude-code" + when [ "claude", :debian ] then "npm install -g @anthropic-ai/claude-code" + when [ "claude", :arch ] then "npm install -g @anthropic-ai/claude-code (or: paru -S claude-code)" + when [ "gh", :macos ] then "brew install gh" + when [ "gh", :debian ] then "sudo apt install gh (or the GitHub apt repo)" + when [ "gh", :arch ] then "sudo pacman -S github-cli && alias gh=github-cli 2>/dev/null || sudo pacman -S gh" + when [ "jq", :macos ] then "brew install jq" + when [ "jq", :debian ] then "sudo apt install jq" + when [ "jq", :arch ] then "sudo pacman -S jq" + when [ "tmux", :macos ] then "brew install tmux" + when [ "tmux", :debian ] then "sudo apt install tmux" + when [ "tmux", :arch ] then "sudo pacman -S tmux" + else nil + end + return "install '#{tool}' with your package manager" if pkg.nil? + + pkg + end + + def os_family + return :macos if RUBY_PLATFORM.include?("darwin") + return :arch if File.exist?("/etc/arch-release") + return :debian if File.exist?("/etc/debian_version") + + :unknown + end + def check_stages STAGES.map { |stage| check_stage(stage) } end @@ -255,7 +360,9 @@ module Hive "version_too_old" => rows.count { |r| r[:status] == "version_too_old" }, "present" => rows.count { |r| r[:status] == "present" }, "not_applicable" => rows.count { |r| r[:status] == "not_applicable" }, - "warning" => rows.count { |r| r[:status] == "warning" } + "warning" => rows.count { |r| r[:status] == "warning" }, + "missing_required" => rows.count { |r| r[:status] == "missing_required" }, + "missing_recommended" => rows.count { |r| r[:status] == "missing_recommended" } } } end @@ -299,10 +406,10 @@ module Hive def row_line(row, widths) marker = case row[:status] when "present" then "✓" - when "missing" then "✗" + when "missing", "missing_required" then "✗" when "version_too_old" then "✗" when "not_applicable" then "—" - when "warning" then "!" + when "warning", "missing_recommended" then "!" else "?" end format("%-#{widths[:label]}s %-#{widths[:agent]}s %-#{widths[:skill]}s #{marker} %-#{widths[:status]}s", diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb index b86e8d610..d51deaf67 100644 --- a/lib/hive/commands/init.rb +++ b/lib/hive/commands/init.rb @@ -9,7 +9,7 @@ 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 +19,10 @@ module Hive # summary_io: $stdout)` runs (UI on stderr, machine-parseable # summary on stdout — see #collect_prompt_answers below). @prompts = prompts + # Optional service backend for U6 daemon registration. Tests + # inject a fake; production resolves Hive::Service lazily so the + # require cost stays off commands that never register services. + @service = service end def call @@ -46,6 +50,42 @@ module Hive print_summary(entry: entry, ops: ops) run_init_preflight! + maybe_register_service + end + + # U6 of the packaging plan: after a successful init, offer to + # register the hive daemon as an OS service (launchd agent on macOS, + # systemd --user unit on Linux). One identical prompt on both + # platforms; the answer drives enable+start vs register-only. + # Non-TTY runs default to register-only and NEVER autostart. + # HIVE_SKIP_SERVICE_REGISTRATION=1 (used by the test suite) skips + # registration entirely. Any backend failure is non-fatal — init + # has already succeeded and the daemon still works in PID-file mode. + def maybe_register_service + svc = @service + if svc.nil? + require "hive/service" + svc = Hive::Service + end + return if svc.skipped? + + platform = svc.platform + return if platform == :none + return if svc.installed?(platform: platform) + + enable = if $stdin.tty? + write_warn("") + write_warn("Register + start the hive daemon now? [Y/n]") + answer = $stdin.gets&.strip.to_s.downcase + answer.empty? || answer == "y" || answer == "yes" + else + write_warn("hive: non-interactive session — registering the daemon service " \ + "without enabling autostart (enable later with: hive daemon enable-service)") + false + end + svc.install!(enable_and_start: enable, platform: platform, output: method(:write_warn)) + rescue StandardError => e + write_warn("hive: daemon service registration failed (continuing): #{e.class}: #{e.message}") end # Non-fatal skill preflight: after init succeeds, run the doctor @@ -73,7 +113,11 @@ module Hive return end - missing = Array(doctor.rows).select { |r| r[:status] == "missing" } + missing = Array(doctor.rows).select do |r| + # Recommended-tool misses stay quiet here — they're hints, not + # blockers, and would nag on every init. Required misses DO warn. + %w[missing missing_required].include?(r[:status]) + end return if missing.empty? emit_preflight_warnings(missing) diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb new file mode 100644 index 000000000..f39244827 --- /dev/null +++ b/lib/hive/commands/uninstall.rb @@ -0,0 +1,231 @@ +require "open3" + +module Hive + module Commands + # `hive uninstall` (packaging plan U8): clean removal that can never + # destroy user work. + # + # Layered behaviour: + # 1. Daemon service registration — unloaded/disabled and the unit + # file removed via Hive::Service.remove! (always, best-effort). + # 2. Channel-managed bits — brew / pacman manage their own binaries, + # so the command prints the EXACT native removal line for the + # operator to run. The install.sh script channel removes its own + # bundle + symlinks directly (it owns those files). + # 3. Project cleanup — strictly opt-in per registered project, + # interactive prompts only. Removes exactly: the .hive-state + # worktree, the hive/state branch, and the global-registry entry. + # Accumulated outputs under .hive-state are PRESERVED BY DEFAULT — + # even --purge never deletes them (--purge only makes layers 1–2 + # non-interactive). Non-TTY runs never delete project state. + # + # Skills installed into agent CLIs are never touched here; they are + # removed through each agent's own mechanism. + class Uninstall + EXIT_OK = 0 + + attr_reader :channel + + def initialize(bin_path: File.expand_path($PROGRAM_NAME), runner: nil, + output: $stdout, input: $stdin, purge: false, + cleanup_projects: nil, repo: nil, service: nil) + @bin_path = bin_path + @runner = runner || Hive::Channel.default_runner + @output = output + @input = input + @purge = purge + # Test/automation hook: explicit per-project opt-in list of project + # names to clean. nil = interactive prompts (TTY) or skip (non-TTY). + @cleanup_projects = cleanup_projects + @repo = repo + # Injectable service backend (U6/U8); production resolves + # Hive::Service lazily. + @service = service + end + + def call + @channel = begin + Hive::Channel.detect(bin_path: @bin_path, runner: @runner) + rescue Hive::Channel::AmbiguousChannel => e + warn "hive: #{e.message}" + warn "hive: proceeding with registration + project cleanup only" + :unknown + end + + remove_service_registration + handle_channel_bits + cleanup_projects_interactive + say "" + say "hive: uninstall complete" + EXIT_OK + end + + private + + def remove_service_registration + svc = @service + if svc.nil? + require "hive/service" + svc = Hive::Service + end + return if svc.skipped? + + say "hive: removing daemon service registration..." + svc.remove!(output: method(:say)) + rescue StandardError => e + say "hive: warning — could not remove daemon service: #{e.class}: #{e.message}" + end + + def handle_channel_bits + case @channel + when :brew + say "" + say " Homebrew manages the hive binary. Finish uninstall with:" + say " brew uninstall hive" + say " brew untap /hive # if you no longer want the tap" + when :pacman + say "" + say " pacman/AUR manages the hive binary. Finish uninstall with:" + say " sudo pacman -Rns hive-bin" + when :script + remove_script_bundle! + else + say "hive: unknown install channel (#@bin_path); no package-manager step to run" + end + end + + # Script-channel installs are fully owned by install.sh: remove the + # version bundle(s), the `current` symlink, and the ~/.local/bin/hive + # (+ hv fallback) symlink. + def remove_script_bundle! + root = Hive::Channel.script_bundle_root(@bin_path) + if root.nil? + say "hive: no install.sh bundle found for #@bin_path" + return + end + + base = File.dirname(root) # ~/.local/opt/hive/ → ~/.local/opt/hive + bin_dir = File.join(File.expand_path("~"), ".local", "bin") + + say "hive: removing bundle #{root}" + FileUtils.rm_rf(root) + + # Drop the `current` symlink when it now dangles. + current = File.join(base, "current") + if File.symlink?(current) + target = begin + File.readlink(current) + rescue StandardError + nil + end + FileUtils.rm_f(current) unless target && File.exist?(target) + end + + %w[hive hv].each do |name| + link = File.join(bin_dir, name) + next unless File.symlink?(link) + + target = begin + File.readlink(link) + rescue StandardError + nil + end + # Only remove symlinks that pointed INTO the removed bundle tree. + if target.to_s.start_with?(base) + FileUtils.rm_f(link) + say "hive: removed symlink #{link}" + end + end + + # Remove the now-empty ~/.local/opt/hive directory. + begin + Dir.rmdir(base) if File.directory?(base) && Dir.empty?(base) + rescue StandardError + nil + end + end + + def cleanup_projects_interactive + projects = begin + Hive::Config.registered_projects + rescue StandardError + [] + end + return if projects.empty? + + say "" + say "Registered projects:" + projects.each { |p| say " - #{p['name']} (#{p['path']})" } + say "" + + targets = select_cleanup_targets(projects) + targets.each { |name| remove_project_hive!(projects.find { |p| p["name"] == name }) } + end + + # Per-project OPT-IN only. Non-TTY never deletes project state — + # even under --purge (purge is non-interactivity for the binary / + # registration layer, not a licence to delete work). + def select_cleanup_targets(projects) + # Explicit opt-in list (automation / --cleanup-projects flag) works + # regardless of TTY — it IS the explicit consent. + if @cleanup_projects.is_a?(Array) + return @cleanup_projects & projects.map { |p| p["name"] } + end + + # Otherwise interactive prompts only; non-TTY never deletes state. + return [] unless @input.respond_to?(:tty?) && @input.tty? + return [] unless interactive_cleanup_allowed? + + targets = [] + projects.each do |p| + say "Remove hive state for #{p['name']} (worktree + hive/state branch)? [y/N]" + answer = @input.gets&.strip.to_s.downcase + targets << p["name"] if answer == "y" || answer == "yes" + end + targets + end + + def interactive_cleanup_allowed? + # Hook for future policy gates; today every path that reaches here + # has already passed the TTY / explicit-list checks in + # select_cleanup_targets. + true + end + + # Remove exactly one registered project's hive content: + # * detach + delete the .hive-state worktree + # * delete the orphan hive/state branch + # * drop the global registry entry + # Task outputs live INSIDE .hive-state, so they go only when the + # operator explicitly opted in above. + def remove_project_hive!(project) + return unless project + + path = project["path"] + state = File.join(path, ".hive-state") + say "hive: cleaning project #{project['name']}" + + if File.exist?(state) + out, err, status = @runner.call(["git", "-C", path, "worktree", "remove", "--force", ".hive-state"]) + unless status.success? + warn "hive: git worktree remove failed for #{project['name']}: #{err.strip}; forcing prune" + @runner.call(["git", "-C", path, "worktree", "prune"]) + FileUtils.rm_rf(state) + end + end + + _out, _err, branch_status = @runner.call(["git", "-C", path, "branch", "-D", "hive/state"]) + warn "hive: no hive/state branch in #{path} (already clean)" unless branch_status.success? + + Hive::Config.unregister_project(name: project["name"]) + say "hive: removed hive content for #{project['name']}" + end + + def say(message) + @output.puts message + rescue Errno::EPIPE + nil + end + end + end +end diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb new file mode 100644 index 000000000..000d518a8 --- /dev/null +++ b/lib/hive/commands/update.rb @@ -0,0 +1,97 @@ +require "open3" + +module Hive + module Commands + # `hive update` (packaging plan U7): upgrade hive through the channel + # that installed it. Hive NEVER copies or swaps its own binary — it + # detects the channel (Homebrew / pacman / install.sh bundle) and + # shells out to that channel's native updater, printing the exact + # command before running it. + # + # :brew → brew upgrade hive + # :pacman → yay -Syu hive-bin (or a printed `sudo pacman -Syu` hint; + # sudo is never run from inside hive) + # :script → re-run the pinned install.sh one-liner (latest release) + # :unknown → manual instructions; exit 1 + # + # --dry-run prints what would run without executing it. + class Update + EXIT_OK = 0 + EXIT_MANUAL = 1 + + CHANNEL_LABELS = { + brew: "Homebrew", + pacman: "pacman", + script: "install.sh bundle", + unknown: "unknown" + }.freeze + + attr_reader :channel + + def initialize(bin_path: File.expand_path($PROGRAM_NAME), runner: nil, + output: $stdout, dry_run: false, repo: nil) + @bin_path = bin_path + @runner = runner || Hive::Channel.default_runner + @output = output + @dry_run = dry_run + @repo = repo + end + + def call + @channel = Hive::Channel.detect(bin_path: @bin_path, runner: @runner) + + if @channel == :unknown + print_unknown_channel_help + return EXIT_MANUAL + end + + command = Hive::Channel.upgrade_command(@channel, runner: @runner, repo: @repo) + if command.nil? + print_pacman_hint + return EXIT_MANUAL + end + + say "hive: detected #{CHANNEL_LABELS[@channel]} install" + say "hive: running: #{command.join(' ')}" + return EXIT_OK if @dry_run + + out, err, status = @runner.call(command) + $stdout.write(out) unless out.to_s.empty? + warn err unless err.to_s.empty? + status.success? ? EXIT_OK : (raise Hive::Error, "#{command.join(' ')} failed") + rescue Hive::Channel::AmbiguousChannel => e + warn "hive: #{e.message}" + raise + end + + private + + def print_unknown_channel_help + say "hive: could not determine how this copy of hive was installed" + say " (#@bin_path matches no known channel)" + say "" + say " Upgrade manually via your install channel:" + say " brew upgrade hive # Homebrew tap" + say " yay -Syu hive-bin # AUR" + say " curl -fsSL /install.sh | bash # one-liner" + say " or re-run the installer you originally used." + end + + def print_pacman_hint + say "hive: detected pacman (AUR hive-bin) install" + say "" + say " Upgrade manually with:" + say " sudo pacman -Syu # full system sync (recommended)" + say " yay -Syu hive-bin # if you use an AUR helper" + say "" + say " hive never runs sudo on your behalf." + end + + def say(message) + @output.puts message + rescue Errno::EPIPE + nil + end + end + end +end diff --git a/lib/hive/config.rb b/lib/hive/config.rb index a67097d8d..db9829eaf 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -160,6 +160,10 @@ module Hive # across every registered project, so runtime code loads these # from ~/Dev/hive/config.yml via load_global_bot. The token lives # only in HIVE_TELEGRAM_BOT_TOKEN and is never persisted. + # The literal pid/log paths below are LEGACY placeholders kept for + # shape stability; Hive::Config.global_bot_defaults overrides them + # with the resolved XDG state-home locations at load time (see U2 + # of the packaging plan). Never read them directly. "bot" => { "enabled" => false, "chat_id_allowlist" => [], @@ -170,11 +174,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 +212,114 @@ module Hive module_function + # ── Global path resolution (XDG layout) ───────────────────────────── + # + # Hive's durable per-project state is /.hive-state/ — a git + # worktree of the orphan branch hive/state. It never moves. Only + # hive's GLOBAL files follow the XDG best-practice layout: + # + # config : $HIVE_CONFIG_HOME → ~/.config/hive/config.yml + # (or $XDG_CONFIG_HOME/hive/config.yml) + # state : ~/.local/state/hive/ (daemon PID, logs) + # cache : ~/.cache/hive/ + # + # Two compatibility modes keep existing installs working: + # + # * Dev-checkout mode: $HIVE_HOME set (pointing at a source tree or + # an old-style home) keeps the pre-XDG layout exactly as before — + # config at $HIVE_HOME/config.yml, daemon PID at $HIVE_HOME/.daemon.pid. + # * Legacy fallback: on a machine where no env vars are set and + # ~/.config/hive/config.yml does not exist but ~/Dev/hive/config.yml + # does, the legacy path is used for reads AND writes so legacy + # users are never stranded; a one-time stderr notice suggests the + # migration. + + def legacy_mode? + e = ENV["HIVE_HOME"] + !(e.nil? || e.empty?) + end + + def xdg_dir(env_var, default_suffix) + dir = ENV[env_var] + return dir unless dir.nil? || dir.empty? + + File.join(File.expand_path("~"), default_suffix) + end + + # Directory holding the global config.yml. Resolution order: + # $HIVE_CONFIG_HOME → $HIVE_HOME (dev-checkout mode) → + # $XDG_CONFIG_HOME/hive → ~/.config/hive + def config_home + dir = ENV["HIVE_CONFIG_HOME"] + return dir unless dir.nil? || dir.empty? + + dir = ENV["HIVE_HOME"] + return dir unless dir.nil? || dir.empty? + + File.join(xdg_dir("XDG_CONFIG_HOME", ".config"), "hive") + end + + # Directory holding mutable runtime state that should survive reboots + # but is not config: the daemon PID file and its logs. + # In dev-checkout ($HIVE_HOME) mode this stays inside HIVE_HOME so a + # source checkout remains fully self-contained. + def state_home + dir = ENV["HIVE_STATE_HOME"] + return File.join(dir, "hive") unless dir.nil? || dir.empty? + + return hive_home if legacy_mode? + + File.join(xdg_dir("XDG_STATE_HOME", ".local/state"), "hive") + end + + # Directory holding non-essential cached data (safe to delete). + def cache_home + dir = ENV["HIVE_CACHE_HOME"] + return File.join(dir, "hive") unless dir.nil? || dir.empty? + + return File.join(hive_home, "cache") if legacy_mode? + + File.join(xdg_dir("XDG_CACHE_HOME", ".cache"), "hive") + end + def hive_home ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive") end + # Pre-XDG global config location. Still honoured as a read+write + # fallback when the XDG path has never been created. + def legacy_global_config_path + File.join(ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive"), "config.yml") + end + + @migration_notice_printed = false + class << self + attr_accessor :migration_notice_printed + end + + def print_migration_notice!(path) + return if migration_notice_printed + + self.migration_notice_printed = true + warn "hive: note — using legacy global config at #{path}; " \ + "consider moving it to #{File.join(config_home, 'config.yml')}" + rescue Errno::EPIPE + nil + end + def global_config_path - File.join(hive_home, "config.yml") + primary = File.join(config_home, "config.yml") + return primary if File.exist?(primary) + + legacy = legacy_global_config_path + return primary if legacy == primary + return primary unless File.exist?(legacy) + + # XDG path absent, legacy present → stay on the legacy file so an + # existing install keeps seeing its registry (and writes land where + # reads happen). The one-time notice points the way forward. + print_migration_notice!(legacy) + legacy end def hive_state_dir(project_root, hive_state_name = ".hive-state") @@ -386,14 +492,14 @@ module Hive def global_bot_defaults defaults = deep_dup(DEFAULTS["bot"]) - defaults["pid_file"] = File.join(hive_home, ".bot.pid") - defaults["log_file"] = File.join(hive_home, "logs", "bot.log") - defaults["last_seen_state_file"] = File.join(hive_home, ".bot.last_seen_update_id") + defaults["pid_file"] = File.join(state_home, ".bot.pid") + defaults["log_file"] = File.join(state_home, "logs", "bot.log") + defaults["last_seen_state_file"] = File.join(state_home, ".bot.last_seen_update_id") defaults end def register_project(name:, path:) - FileUtils.mkdir_p(hive_home) + FileUtils.mkdir_p(File.dirname(global_config_path)) data = if File.exist?(global_config_path) load_global_config(global_config_path) else diff --git a/lib/hive/service.rb b/lib/hive/service.rb new file mode 100644 index 000000000..338b50dbf --- /dev/null +++ b/lib/hive/service.rb @@ -0,0 +1,243 @@ +require "erb" +require "fileutils" + +module Hive + # OS service registration for the hive daemon (packaging plan U6). + # + # Two backends: + # * :launchd — macOS user agent at ~/Library/LaunchAgents/dev.hive.daemon.plist + # * :systemd — Linux user unit at ~/.config/systemd/user/hive.service + # + # `hive init` offers one identical prompt on both platforms ("Register + + # start the hive daemon now? [Y/n]"); the answer drives enable+start vs + # register-only. Non-TTY runs NEVER autostart — they register the unit + # file only. Set HIVE_SKIP_SERVICE_REGISTRATION=1 to skip registration + # entirely (used by the test suite). + # + # When a service is registered, `hive daemon {start,stop,status}` + # delegate to launchctl / systemctl --user; otherwise they fall back to + # the historical PID-file mode (dev checkouts). + module Service + LABEL = "dev.hive.daemon".freeze + UNIT_NAME = "hive.service".freeze + + class ServiceError < Hive::Error; end + + module_function + + # Detect the service backend for this machine. :none means "no + # backend" (unsupported OS, or Linux without a user systemd instance) + # and every entry point becomes a no-op. + def platform + return :launchd if RUBY_PLATFORM.include?("darwin") + return :systemd if File.exist?("/run/systemd/system") || File.exist?("/run/user/#{Process.uid}/systemd") + + :none + end + + # Path of the unit/plist file for the given platform. `home:` is + # injectable for tests; production resolves ~. + def unit_path(platform: nil, home: nil) + platform ||= self.platform + base = home || File.expand_path("~") + case platform + when :launchd then File.join(base, "Library", "LaunchAgents", "#{LABEL}.plist") + when :systemd then File.join(base, ".config", "systemd", "user", UNIT_NAME) + else nil + end + end + + def installed?(platform: nil, home: nil) + path = unit_path(platform: platform, home: home) + !path.nil? && File.exist?(path) + end + + def skipped? + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] == "1" + end + + # Render the unit/plist content for the current installation. Both + # backends run `hive daemon start` in the FOREGROUND: launchd and + # systemd both expect a long-lived child they can supervise. + def render_unit(platform:, hive_bin:, log_file:) + template_path = File.expand_path("../../templates/#{template_name(platform)}", __dir__) + template = File.read(template_path) + erb = ERB.new(template, trim_mode: "-") + erb.result(binding) + end + + def template_name(platform) + case platform + when :launchd then "dev.hive.daemon.plist.erb" + when :systemd then "hive.service.erb" + else raise ServiceError, "no service template for platform #{platform.inspect}" + end + end + + # Write the unit file and optionally enable + start it. Idempotent: + # re-running overwrites the unit in place. Returns the unit path. + # + # `runner:` is the command executor (injectable for tests); it takes + # an argv Array and returns [stdout, stderr, status]. + def install!(enable_and_start:, platform: nil, home: nil, hive_bin: nil, + log_file: nil, runner: nil, output: $stderr) + platform ||= self.platform + return nil if platform == :none + + hive_bin ||= default_hive_bin + log_file ||= File.join(Hive::Config.state_home, "logs", "daemon.log") + runner ||= ->(argv) { Open3.capture3(*argv) } + + path = unit_path(platform: platform, home: home) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, render_unit(platform: platform, hive_bin: hive_bin, log_file: log_file)) + + argvs = if enable_and_start + enable_argv(platform: platform, home: home) + else + reload_argv(platform: platform, home: home) + end + report_failure!(output, path, argvs) do + argvs.each do |argv| + _out, err, status = run(runner, argv) + raise ServiceError, "#{argv.join(' ')} failed: #{err.strip}" unless status.success? + end + end + + verb = enable_and_start ? "registered + started" : "registered (not enabled)" + say(output, "hive: daemon service #{verb}: #{path}") + path + end + + # Remove the registration: disable/stop first, then delete the unit. + # Safe to call repeatedly (idempotent), safe to call when nothing is + # registered. Never raises on backend-command failure — uninstall + # must always make progress. + def remove!(platform: nil, home: nil, runner: nil, output: $stderr) + platform ||= self.platform + path = unit_path(platform: platform, home: home) + return false if path.nil? || !File.exist?(path) + + runner ||= ->(argv) { Open3.capture3(*argv) } + remove_argv(platform: platform, home: home).each do |argv| + begin + run(runner, argv) + rescue StandardError + # best-effort: the file removal below is the authoritative step + end + end + FileUtils.rm_f(path) + say(output, "hive: daemon service removed: #{path}") + true + end + + # ── daemon delegation (used by `hive daemon {start,stop,status}`) ── + + def running?(platform: nil, home: nil, runner: nil) + platform ||= self.platform + return false unless installed?(platform: platform, home: home) + + runner ||= ->(argv) { Open3.capture3(*argv) } + case platform + when :launchd + _out, _err, status = run(runner, ["launchctl", "print", "gui/#{Process.uid}/#{LABEL}"]) + status.success? + when :systemd + out, _err, _status = run(runner, ["systemctl", "--user", "is-active", UNIT_NAME]) + out.strip == "active" + else + false + end + end + + def start!(platform: nil, home: nil, runner: nil) + platform ||= self.platform + return false unless installed?(platform: platform, home: home) + + runner ||= ->(argv) { Open3.capture3(*argv) } + argv = case platform + when :launchd then ["launchctl", "start", LABEL] + when :systemd then ["systemctl", "--user", "start", UNIT_NAME] + end + _out, err, status = run(runner, argv) + raise ServiceError, "service start failed: #{err.strip}" unless status.success? + + true + end + + def stop!(platform: nil, home: nil, runner: nil) + platform ||= self.platform + return false unless installed?(platform: platform, home: home) + + runner ||= ->(argv) { Open3.capture3(*argv) } + argv = case platform + when :launchd then ["launchctl", "stop", LABEL] + when :systemd then ["systemctl", "--user", "stop", UNIT_NAME] + end + run(runner, argv) + true + end + + # ── internals ──────────────────────────────────────────────────────── + + def default_hive_bin + File.expand_path($PROGRAM_NAME) + end + + def enable_argv(platform:, home: nil) + case platform + when :launchd + [ ["launchctl", "unload", "-w", unit_path(platform: platform, home: home)], + ["launchctl", "load", "-w", unit_path(platform: platform, home: home)] ] + when :systemd + reload_argv(platform: platform, home: home) + + [ ["systemctl", "--user", "enable", "--now", UNIT_NAME] ] + else + [] + end + end + + # Commands for register-only (write unit, never autostart): systemd + # needs a daemon-reload so the new unit is known; launchd needs nothing + # — an unloaded plist is inert by definition. + def reload_argv(platform:, home: nil) + case platform + when :systemd + [ ["systemctl", "--user", "daemon-reload"] ] + else + [] + end + end + + def remove_argv(platform:, home: nil) + case platform + when :launchd + [ ["launchctl", "unload", "-w", unit_path(platform: platform, home: home)] ] + when :systemd + [ ["systemctl", "--user", "disable", "--now", UNIT_NAME], + ["systemctl", "--user", "daemon-reload"] ] + else + [] + end + end + + def run(runner, argv) + runner.call(argv) + end + + def report_failure!(output, path, argvs) + yield + rescue StandardError => e + say(output, "hive: warning — service commands failed after writing #{path}: #{e.message}") + say(output, " enable manually with: #{argvs.map { |a| a.join(' ') }.join(' && ')}") + end + + def say(output, message) + output.call(message) + rescue NoMethodError + output.puts message + rescue Errno::EPIPE + nil + end + end +end diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 000000000..7ad0560a3 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,96 @@ +# Packaging: release engineering & distribution channels + +This directory owns everything that turns a `v*` git tag into installable +artifacts for hive's tier-1 platforms. + +## Artifact format decision: portable bundle, NOT a packed binary + +**Decision (2026-08, packaging plan U1):** every Release ships a +self-contained *portable directory bundle* — vendored Ruby (`opt/ruby/`, +built via ruby-build from `.ruby-version`), the application tree, bundled +gems (`vendor/bundle/`), and a symlink-safe launcher shim at the archive +root. No system Ruby is required at runtime. + +A single-file packer (Tebako / ruby-packer) was time-boxed as a spike and +**rejected**: hive's TUI is driven by `bubbletea` and `lipgloss`, which are +FFI bindings to platform-specific prebuilt Go static libraries +(`libbubbletea.a` / `bubbletea.so`). Single-file packers have their worst +failure modes exactly there — embedded native extensions with per-platform +binaries. The directory bundle sidesteps the problem entirely, keeps the +FFI libs on real disk where dlopen expects them, and remains a single +`tar.gz` download. If a future spike proves both packers work against the +Charm gems, this decision can be revisited; until then the bundle is the +contract every channel consumes. + +### Bundle layout + +``` +hive--.tar.gz +└── hive--/ + ├── hive # launcher shim (symlink-safe, becomes bin/hive) + ├── .hive-bundle # version marker — Hive::Channel's script-channel signal + ├── Gemfile, Gemfile.lock, .ruby-version + ├── lib/ bin/ templates/ schemas/ config/ + └── opt/ruby/ # vendored Ruby + └── vendor/bundle/ # bundled gems (development/test excluded) +``` + +The shim resolves its own path through symlinks, so the same file works +when linked from `~/.local/bin/hive`, Homebrew's `bin/hive`, or +`/usr/bin/hive`. + +## Build matrix + +| Platform | Runner | Tier | +|------------------|---------------|------| +| `macos-arm64` | macos-14 | 1 | +| `macos-x86_64` | macos-13 | 2 | +| `linux-x86_64-gnu` | ubuntu-22.04 | 1 | + +Linux artifacts are built on the **oldest** supported tier-1 runner so the +bundled Ruby's glibc requirement covers Ubuntu 22.04+. Arch's forward-only +glibc is fine; older-glibc breakage cannot happen by construction. + +Tier-3 (unsupported): Alpine/musl, NixOS, BSDs, Windows-native. WSL2 rides +the linux-x86_64-gnu artifact as tier-2. + +## Pipeline + +`.github/workflows/release.yml` triggers on `v*` tags: + +1. Per-platform job runs `packaging/build-bundle.sh`. +2. The script smoke-checks the bundle in a scrubbed environment + (`env -i ./hive --version`) proving no system Ruby is needed. +3. Tarball + per-platform sha256 fragment are uploaded. +4. A release job concatenates fragments into `SHA256SUMS`, attaches + `packaging/install.sh` verbatim, and publishes the GitHub Release. + +The Release is the canonical artifact source for all four channels: + +| Channel | Consumes | Where | +|---------|----------|-------| +| `curl \| bash` one-liner | tarball + SHA256SUMS + install.sh | `packaging/install.sh` | +| Homebrew tap `/hive` | macOS tarballs | `packaging/homebrew/Formula/hive.rb` | +| AUR `hive-bin` | linux tarball | `packaging/aur/PKGBUILD` | +| install.md prompt | all of the above | `/install.md` | + +## Local build + +```sh +brew install ruby-build # or apt/rpm equivalent, or rbenv's ruby-build +packaging/build-bundle.sh dist +env -i HOME=$(mktemp -d) PATH=/usr/bin:/bin dist/hive-*/hive --version # smoke +``` + +`HIVE_VENDORED_RUBY=/path/to/ruby-prefix` skips the compile step by reusing +an existing Ruby built for the current platform. + +## Known caveats + +* **macOS Gatekeeper:** downloaded tarballs carry `com.apple.quarantine` + (no notarization in v1; bundles are ad-hoc signed only). One-time fix: + `xattr -d com.apple.quarantine hive-*.tar.gz`. The brew channel avoids + quarantine entirely — prefer it on macOS. +* **glibc:** see matrix note above. musl/Alpine is out of scope. +* **AUR publishing is manual** (needs an AUR account + SSH key): see + `packaging/aur/README.md` for the maintainer checklist after each tag. diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD new file mode 100644 index 000000000..317bb2461 --- /dev/null +++ b/packaging/aur/PKGBUILD @@ -0,0 +1,34 @@ +# Maintainer : see https://github.com/ivankuznetsov/hive +# Upstream : https://github.com/ivankuznetsov/hive +# AUR package: hive-bin (prebuilt Release bundle; hive-git may come later) + +pkgname=hive-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Folder-as-agent pipeline: LLM-driven task orchestration CLI (prebuilt binary bundle)" +arch=('x86_64') +url="https://github.com/ivankuznetsov/hive" +license=('MIT') +depends=('bash' 'git') +optdepends=('claude-code: default agent CLI' + 'github-cli: PR automation' + 'jq: JSON tooling' + 'tmux: interactive brainstorm runtime') +provides=('hive') +conflicts=('hive') +source=("hive-${pkgver}-linux-x86_64-gnu.tar.gz::https://github.com/ivankuznetsov/hive/releases/download/v${pkgver}/hive-${pkgver}-linux-x86_64-gnu.tar.gz") +# Filled by packaging/aur/generate-srcinfo.sh from the Release SHA256SUMS +sha256sums=('SKIP') + +# The bundle is self-contained (vendored Ruby + gems): no system ruby +# dependency, by contract (see packaging/README.md). +options=('!strip') + +package() { + install -d -m 755 "${pkgdir}/opt/hive" + cp -r "${srcdir}/hive-${pkgver}-linux-x86_64-gnu" "${pkgdir}/opt/hive/${pkgver}" + + # /usr/bin/hive → the symlink-safe shim inside the bundle. + install -d -m 755 "${pkgdir}/usr/bin" + ln -s "/opt/hive/${pkgver}/hive" "${pkgdir}/usr/bin/hive" +} diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 000000000..184cf48b0 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,54 @@ +# AUR: `hive-bin` + +Prebuilt-binary AUR package fed from tagged GitHub Releases. The package +name is always `hive-bin` (with `provides=('hive')` / +`conflicts=('hive')`); the Apache-Hive `hv` fallback lives in the bundle +shim / install.sh, never in the package name. + +This directory is the source of truth; the AUR repo `hive-bin` mirrors it. + +## Maintainer checklist (per release tag `vX.Y.Z`) + +Publishing to the AUR is manual (requires an AUR account + uploaded SSH +key); automation can prepare but not push. + +1. Wait for the release workflow to attach + `hive-X.Y.Z-linux-x86_64-gnu.tar.gz` + `SHA256SUMS`. +2. Regenerate metadata with the real digest: + ```sh + cd packaging/aur + ./generate-srcinfo.sh X.Y.Z # fetches SHA256SUMS itself + # or offline: ./generate-srcinfo.sh X.Y.Z /path/to/SHA256SUMS + ``` +3. Sanity-build in a clean chroot: + ```sh + makechrootpkg -r $CHROOT # or: makepkg in a workdir copy + namcap PKGBUILD + ``` +4. Test-install on a clean Arch VM/container: + ```sh + pacman -U hive-bin-X.Y.Z-1-x86_64.pkg.tar.zst + hive --version + pacman -Ql hive-bin | head # sanity + ``` +5. Upgrade-path check between two consecutive tags (clone the AUR repo, + commit the new PKGBUILD + .SRCINFO, `git push`): + ```sh + git clone ssh://aur@aur.archlinux.org/hive-bin.git + cp PKGBUILD .SRCINFO hive-bin.git/ && cd hive-bin.git + git commit -am "hive-bin X.Y.Z" && git push + ``` +6. Verify `yay -S hive-bin` and `yay -Syu` pick up the bump. + +## Acceptance (plan scenario B) + +Clean Arch VM: + +```sh +yay -S hive-bin +hive --version # X.Y.Z +cd ~/some-project && hive init +# upgrade between tags: +yay -Syu hive-bin # X.Y.Z → X.Y.Z+1 +sudo pacman -Rns hive-bin # clean removal; `hive uninstall` prints this line too +``` diff --git a/packaging/aur/generate-srcinfo.sh b/packaging/aur/generate-srcinfo.sh new file mode 100755 index 000000000..350d5a08e --- /dev/null +++ b/packaging/aur/generate-srcinfo.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# packaging/aur/generate-srcinfo.sh — regenerate .SRCINFO for the AUR +# hive-bin package with real sha256 digests pulled from a Release. +# +# Usage: +# packaging/aur/generate-srcinfo.sh [SHA256SUMS-file] +# release version WITHOUT the leading v (e.g. 0.1.0) +# [SHA256SUMS-file] optional local SHA256SUMS; otherwise fetched from +# https://github.com/ivankuznetsov/hive/releases/download/v/SHA256SUMS +# +# Run this inside packaging/aur/ (makepkg must be available; on non-Arch +# hosts use a clean chroot or devtools container). + +set -euo pipefail + +VERSION="${1:?usage: generate-srcinfo.sh [SHA256SUMS-file]}" +SUMS="${2:-}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +log() { printf '[srcinfo] %s\n' "$*"; } +die() { printf '[srcinfo] ERROR: %s\n' "$*" >&2; exit 1; } + +TARBALL="hive-${VERSION}-linux-x86_64-gnu.tar.gz" + +if [[ -z "$SUMS" ]]; then + SUMS="$(mktemp)" + trap 'rm -f "$SUMS"' EXIT + log "fetching SHA256SUMS for v${VERSION}" + curl -fsSL -o "$SUMS" \ + "https://github.com/ivankuznetsov/hive/releases/download/v${VERSION}/SHA256SUMS" +fi + +SHA="$(grep " ${TARBALL}\$" "$SUMS" | awk '{print $1}')" +[[ -n "$SHA" ]] || die "no checksum entry for $TARBALL in $SUMS" + +log "digest: $SHA" +sed -i "s|^sha256sums=('.*')|sha256sums=('${SHA}')|" "$HERE/PKGBUILD" +sed -i "s|^pkgver=.*|pkgver=${VERSION}|" "$HERE/PKGBUILD" + +command -v makepkg >/dev/null 2>&1 || + die "makepkg not found; run inside an Arch chroot/container (see README)" + +( cd "$HERE" && makepkg --printsrcinfo > .SRCINFO ) +log "wrote $HERE/.SRCINFO" +log "next: follow packaging/aur/README.md to publish to the AUR" diff --git a/packaging/build-bundle.sh b/packaging/build-bundle.sh new file mode 100755 index 000000000..0e892ed7d --- /dev/null +++ b/packaging/build-bundle.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# packaging/build-bundle.sh — build one portable hive release bundle. +# +# Produces a self-contained tarball (vendored Ruby + app + bundled gems + +# launcher shim). This is the DECIDED distribution format (see +# packaging/README.md): a single-file packer (Tebako / ruby-packer) was +# time-boxed and rejected because hive's TUI depends on bubbletea/lipgloss, +# which are FFI bindings to platform-specific prebuilt Go static libraries — +# exactly the shape single-file packers handle worst. A portable directory +# bundle sidesteps the problem entirely and still needs no system Ruby. +# +# Usage: +# packaging/build-bundle.sh [OUTPUT_DIR] # default: dist +# +# Environment: +# HIVE_BUILD_VERSION override version string (default: derived from git) +# HIVE_VENDORED_RUBY path to an already-built Ruby prefix to reuse +# (skips the ruby-build compile step) +# +# Requirements on the build host: +# * ruby-build (https://github.com/rbenv/ruby-build) on PATH, OR +# HIVE_VENDORED_RUBY pointing at a Ruby built for this platform +# * bundler (any recent Ruby works to drive `bundle install`; the +# resulting vendor/bundle is consumed by the vendored Ruby) +# * tar, gzip, sha256sum (shasum on macOS is handled transparently) + +set -euo pipefail + +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${1:-$APP_ROOT/dist}" + +log() { printf '[build-bundle] %s\n' "$*"; } +die() { printf '[build-bundle] ERROR: %s\n' "$*" >&2; exit 1; } + +# ── version ─────────────────────────────────────────────────────────────── +if [[ -n "${HIVE_BUILD_VERSION:-}" ]]; then + VERSION="$HIVE_BUILD_VERSION" +else + VERSION="$(git -C "$APP_ROOT" describe --tags --exact-match 2>/dev/null || + git -C "$APP_ROOT" rev-parse --short HEAD)" || die "cannot derive version" +fi +VERSION="${VERSION#v}" + +# ── platform triple ─────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$OS:$ARCH" in + Darwin:arm64) PLATFORM="macos-arm64" ;; + Darwin:x86_64) PLATFORM="macos-x86_64" ;; + Linux:x86_64) PLATFORM="linux-x86_64-gnu" ;; # built on oldest tier-1 glibc + *) die "unsupported platform: $OS:$ARCH (tier-3, see packaging/README.md)" ;; +esac + +STAGE_NAME="hive-${VERSION}-${PLATFORM}" +STAGE="$OUT_DIR/$STAGE_NAME" +rm -rf "$STAGE" +mkdir -p "$STAGE" + +log "building $STAGE_NAME" + +# ── application tree ────────────────────────────────────────────────────── +for item in lib bin templates schemas config Gemfile Gemfile.lock .ruby-version LICENSE README.md; do + if [[ -e "$APP_ROOT/$item" ]]; then + cp -R "$APP_ROOT/$item" "$STAGE/" + fi +done + +# Bundle marker consumed by Hive::Channel (update/uninstall channel +# detection) — authoritative "this came from install.sh/a Release". +printf '%s\n' "$VERSION" > "$STAGE/.hive-bundle" + +# ── vendored Ruby ───────────────────────────────────────────────────────── +RUBY_VERSION="$(tr -d '[:space:]' < "$APP_ROOT/.ruby-version")" +VENDORED="$STAGE/opt/ruby" + +if [[ -n "${HIVE_VENDORED_RUBY:-}" ]]; then + log "reusing vendored Ruby from HIVE_VENDORED_RUBY=$HIVE_VENDORED_RUBY" + mkdir -p "$STAGE/opt" + cp -R "$HIVE_VENDORED_RUBY/" "$VENDORED/" +elif command -v ruby-build >/dev/null 2>&1; then + log "compiling Ruby $RUBY_VERSION via ruby-build (this takes several minutes)" + ruby-build "$RUBY_VERSION" "$VENDORED" +else + die "no Ruby source available: install ruby-build or set HIVE_VENDORED_RUBY" +fi + +RUBY_BIN="$VENDORED/bin/ruby" +[[ -x "$RUBY_BIN" ]] || die "vendored Ruby missing at $RUBY_BIN" + +# ── bundled gems ────────────────────────────────────────────────────────── +log "vendoring gems (excluding development/test groups)" +( + cd "$STAGE" + export PATH="$VENDORED/bin:$PATH" + gem install bundler --no-document + bundle config set --local path vendor/bundle + bundle config set --local without "development test" + bundle config set --local frozen true + bundle install +) || die "bundle install failed" + +# ── launcher shim ───────────────────────────────────────────────────────── +# Symlink-safe: resolves $BASH_SOURCE through any number of symlinks so the +# same shim works when linked from ~/.local/bin/hive, Homebrew's bin/, or +# invoked directly inside the bundle. +cat > "$STAGE/hive" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac +done +BUNDLE_ROOT="$(cd -P "$(dirname "$SOURCE")" && pwd)" +export BUNDLE_GEMFILE="$BUNDLE_ROOT/Gemfile" +export RUBYOPT="-rbundler/setup" +exec "$BUNDLE_ROOT/opt/ruby/bin/ruby" "$BUNDLE_ROOT/bin/hive" "$@" +SHIM +chmod +x "$STAGE/hive" + +# Sanity gate: the shim must be able to print the version with a scrubbed +# environment (proves the bundle does NOT need system Ruby or system gems). +scrub_env=(env -i HOME="$(mktemp -d)" PATH="/usr/bin:/bin") +if ! "${scrub_env[@]}" "$STAGE/hive" --version >/dev/null; then + die "smoke check failed: ./hive --version did not run in a scrubbed environment" +fi +log "smoke check passed: ./hive --version runs without system Ruby" + +# ── archive ─────────────────────────────────────────────────────────────── +mkdir -p "$OUT_DIR" +TARBALL="$OUT_DIR/$STAGE_NAME.tar.gz" +tar -czf "$TARBALL" -C "$OUT_DIR" "$STAGE_NAME" + +# ── checksum ────────────────────────────────────────────────────────────── +if command -v sha256sum >/dev/null 2>&1; then + (cd "$OUT_DIR" && sha256sum "$STAGE_NAME.tar.gz" > "$STAGE_NAME.tar.gz.sha256") +else + (cd "$OUT_DIR" && shasum -a 256 "$STAGE_NAME.tar.gz" > "$STAGE_NAME.tar.gz.sha256") +fi + +log "built $TARBALL" +log "checksum $TARBALL.sha256" diff --git a/packaging/homebrew/Formula/hive.rb b/packaging/homebrew/Formula/hive.rb new file mode 100644 index 000000000..c2986a888 --- /dev/null +++ b/packaging/homebrew/Formula/hive.rb @@ -0,0 +1,54 @@ +# Homebrew tap formula for hive (packaging plan U4). +# +# This file is the SOURCE OF TRUTH. It is mirrored into the separate tap +# repo /homebrew-hive (Formula/hive.rb) on every release — see +# packaging/homebrew/README.md for the sync checklist. Users install with: +# +# brew install /hive/hive +# +# The formula consumes the prebuilt Release bundle (vendored Ruby inside), +# so there is deliberately no system-Ruby dependency — the bundle is +# self-contained by contract (see packaging/README.md). +class Hive < Formula + desc "Folder-as-agent pipeline: LLM-driven task orchestration CLI" + homepage "https://github.com/ivankuznetsov/hive" + version "0.1.0" + license "MIT" + + depends_on :macos + + # Bottled-style: download the prebuilt per-arch Release bundle. Both + # tier-1/tier-2 macOS targets are built by .github/workflows/release.yml. + if Hardware::CPU.arm? + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-#{version}-macos-arm64.tar.gz" + sha256 "PLACEHOLDER-ARM64-SHA256" # filled by the release bump workflow + else + url "https://github.com/ivankuznetsov/hive/releases/download/v#{version}/hive-#{version}-macos-x86_64.tar.gz" + sha256 "PLACEHOLDER-X86_64-SHA256" # filled by the release bump workflow + end + + def install + # The archive is the portable bundle; keep it intact inside the Cellar + # so opt/ruby + vendor/bundle stay relative to the shim. + libexec.install Dir["*"] + libexec.install ".hive-bundle" + + # bin/hive is the symlink-safe shim; it resolves its own real path so + # the Homebrew symlink works unchanged. + bin.install libexec/"hive" + end + + def caveats + <<~EOS + hive ships a vendored Ruby; no system Ruby is required. + + If another `hive` command (e.g. Apache Hive) shadows this one after + install, run `brew unlink hive && brew link --overwrite hive`, or + invoke the binary as `#{opt_bin}/hive` directly. + EOS + end + + test do + assert_match version.to_s, shell_output("#{bin}/hive --version") + end +end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 000000000..a63112023 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,37 @@ +# Homebrew tap: `/homebrew-hive` + +The tap lives in its own GitHub repo so `brew tap /hive && +brew install /hive/hive` works day one. This directory is the source +of truth; the tap repo mirrors it. + +## Sync checklist (per release tag `vX.Y.Z`) + +1. Wait for the release workflow (`.github/workflows/release.yml`) to go + green and attach `hive-X.Y.Z-macos-*.tar.gz` + `SHA256SUMS`. +2. Compute the per-arch digests: + ```sh + grep macos-arm64 SHA256SUMS + grep macos-x86_64 SHA256SUMS + ``` +3. Update `Formula/hive.rb` (copy from `packaging/homebrew/Formula/hive.rb`): + set `version "X.Y.Z"` and replace both `PLACEHOLDER-*-SHA256` values. +4. Validate locally: + ```sh + brew install --build-from-source ./Formula/hive.rb + brew test hive + brew audit --strict hive + ``` +5. Commit + push the tap repo. (Automation option for later: a release-job + step opening a `brew bump-formula-pr` against the tap.) + +## Acceptance (plan scenario A) + +Clean macOS arm64 VM: + +```sh +brew tap /hive https://github.com//homebrew-hive +brew install /hive/hive +hive --version # X.Y.Z +cd ~/some-project && hive init +brew uninstall hive # leaves no stray files (verify with `brew list`) +``` diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100755 index 000000000..a1ad6b838 --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# +# hive installer — `curl -fsSL /install.sh | bash` +# +# Installs a self-contained hive bundle (vendored Ruby — no system Ruby +# needed) under ~/.local/opt/hive// and symlinks the launcher +# into ~/.local/bin. +# +# Behaviour (packaging plan U3): +# * OS/arch detection with glibc presence check on Linux +# * downloads the latest pinned GitHub Release (or HIVE_VERSION) +# * verifies SHA256 against the Release's SHA256SUMS +# * idempotent: re-running upgrades in place +# * if another `hive` already owns PATH (e.g. Apache Hive), installs as +# `hv` instead and says so loudly +# * hard-fails with hints when git/bash/curl are missing; warns with +# hints (never auto-installs) for claude/gh/jq +# +# Environment overrides: +# HIVE_REPO GitHub slug (default ivankuznetsov/hive) +# HIVE_VERSION version WITHOUT the v (default: latest Release) +# HIVE_PREFIX install root (default $HOME/.local) + +set -euo pipefail + +REPO="${HIVE_REPO:-ivankuznetsov/hive}" +PREFIX="${HIVE_PREFIX:-$HOME/.local}" +INSTALL_ROOT="$PREFIX/opt/hive" +BIN_DIR="$PREFIX/bin" + +log() { printf '[hive-installer] %s\n' "$*"; } +warn() { printf '[hive-installer] WARNING: %s\n' "$*" >&2; } +die() { printf '[hive-installer] ERROR: %s\n' "$*" >&2; exit 1; } + +have() { command -v "$1" >/dev/null 2>&1; } + + +# Resolve a command path through symlinks (portable readlink -f). +resolve_path() { + local target="$1" + if [[ "$(uname -s)" != "Darwin" ]] && have readlink && readlink -f / >/dev/null 2>&1; then + readlink -f "$target" + return + fi + local source="$target" + while [[ -L "$source" ]]; do + local dir + dir="$(cd -P "$(dirname "$source")" && pwd)" + source="$(readlink "$source")" + case "$source" in /*) ;; *) source="$dir/$source" ;; esac + done + printf '%s\n' "$source" +} + +install_hint() { + local tool="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + case "$tool" in + git) echo "brew install git" ;; + bash) echo "brew install bash" ;; + curl) echo "brew install curl" ;; + tar) echo "tar ships with macOS" ;; + claude) echo "npm install -g @anthropic-ai/claude-code" ;; + gh) echo "brew install gh" ;; + jq) echo "brew install jq" ;; + *) echo "install '$tool' with your package manager" ;; + esac + elif [[ -f /etc/arch-release ]]; then + case "$tool" in + git|curl|tar|jq) echo "sudo pacman -S $tool" ;; + bash) echo "bash ships with Arch" ;; + gh) echo "sudo pacman -S github-cli" ;; + claude) echo "npm install -g @anthropic-ai/claude-code" ;; + *) echo "install '$tool' with pacman" ;; + esac + else + case "$tool" in + git|curl|jq) echo "sudo apt-get install -y $tool" ;; + bash) echo "bash ships with this distro" ;; + tar) echo "tar ships with this distro" ;; + gh) echo "see https://github.com/cli/cli#installation" ;; + claude) echo "npm install -g @anthropic-ai/claude-code" ;; + *) echo "install '$tool' with your package manager" ;; + esac + fi +} + +# ── dependency check ────────────────────────────────────────────────────── +for tool in curl bash git tar; do + have "$tool" || die "missing required tool '$tool'. Install it first, e.g.: $(install_hint "$tool")" +done + +warn_optional() { + local tool="$1" + have "$tool" && return 0 + warn "'$tool' is recommended but not installed — some features will not work." + warn " hint: $(install_hint "$tool") (hive does NOT auto-install tools)" +} + +warn_optional claude +warn_optional gh +warn_optional jq + +# ── platform detection ──────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$OS:$ARCH" in + Darwin:arm64) PLATFORM="macos-arm64" ;; + Darwin:x86_64) PLATFORM="macos-x86_64" ;; + Linux:x86_64) PLATFORM="linux-x86_64-gnu" + # glibc presence sanity (Alpine/musl is tier-3: unsupported). + if ! have ldd; then + die "linux-x86_64-gnu builds require glibc (ldd not found). Alpine/musl is not supported (tier-3)." + fi ;; + *) die "unsupported platform $OS:$ARCH. Supported: macOS arm64/x86_64, Linux x86_64 (glibc). See README tier table." ;; +esac + +# ── resolve version ─────────────────────────────────────────────────────── +if [[ -n "${HIVE_VERSION:-}" ]]; then + VERSION="${HIVE_VERSION#v}" +else + log "resolving latest release..." + VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)" + [[ -n "$VERSION" ]] || die "could not resolve latest release tag from github.com/$REPO" + VERSION="${VERSION#v}" +fi + +TARBALL="hive-${VERSION}-${PLATFORM}.tar.gz" +BASE_URL="https://github.com/$REPO/releases/download/v${VERSION}" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +# ── download + verify ───────────────────────────────────────────────────── +log "downloading $TARBALL" +curl -fsSL -o "$TMP_DIR/$TARBALL" "$BASE_URL/$TARBALL" + +log "verifying SHA256 against the Release checksums" +curl -fsSL -o "$TMP_DIR/SHA256SUMS" "$BASE_URL/SHA256SUMS" +( + cd "$TMP_DIR" + grep " $TARBALL\$" SHA256SUMS > .expected || die "no checksum entry for $TARBALL in SHA256SUMS" + if have sha256sum; then + sha256sum -c .expected + else + shasum -a 256 -c .expected + fi +) || die "checksum verification FAILED — refusing to install" + +# ── install bundle ──────────────────────────────────────────────────────── +EXTRACT_DIR="$TMP_DIR/hive-${VERSION}-${PLATFORM}" +mkdir -p "$INSTALL_ROOT" "$BIN_DIR" +tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" +[[ -x "$EXTRACT_DIR/hive" ]] || die "downloaded bundle is malformed (no ./hive launcher)" + +log "installing to $INSTALL_ROOT/$VERSION" +rm -rf "$INSTALL_ROOT/$VERSION" +mv "$EXTRACT_DIR" "$INSTALL_ROOT/$VERSION" +ln -sfn "$INSTALL_ROOT/$VERSION" "$INSTALL_ROOT/current" + +# ── launcher symlink (+ hv collision fallback) ──────────────────────────── +LINK_NAME="hive" +existing="$(command -v hive 2>/dev/null || true)" +if [[ -n "$existing" ]]; then + resolved="$(resolve_path "$existing")" + case "$resolved" in + "$INSTALL_ROOT"/*) : ;; # our own previous install — upgrade in place + *) + LINK_NAME="hv" + warn "a different 'hive' command already exists at $existing" + warn "(Apache Hive? something else?) — installing as 'hv' instead." + warn "Run 'hv --version'; use 'hv' wherever docs say 'hive'." + ;; + esac +fi +ln -sfn "$INSTALL_ROOT/current/hive" "$BIN_DIR/$LINK_NAME" + +# ── PATH notice ─────────────────────────────────────────────────────────── +case ":$PATH:" in + *":$BIN_DIR:"*) : ;; + *) warn "$BIN_DIR is not on your PATH." + warn " Add it: echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc (or your shell profile)" ;; +esac + +log "installed hive $VERSION as '$LINK_NAME'" +"$BIN_DIR/$LINK_NAME" --version && log "verification passed: hive --version OK" +log "next steps:" +log " cd into a project and run: $LINK_NAME init" + +exit 0 diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..67cabf561 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,45 @@ +# hive-skills — agent skills package + +The skills hive's stage agents invoke (`/plan`, `/compound-engineering:ce-*`, +reviewer skills) are distributed SEPARATELY from the hive CLI, through each +agent's own marketplace mechanism. The core installer (`install.sh`, brew, +AUR) and `hive init` never install skills — see packaging plan R7. + +## Package manifest + +`marketplace.json` describes the package for marketplace-based agents. +Today's primary target is the Claude Code plugin marketplace; Codex and Pi +channels are added as their marketplace flows stabilize. + +## Layout + +``` +skills/ +├── marketplace.json # package manifest (name, version, skill roots) +└── README.md +``` + +Skill sources themselves live upstream (llm-wiki's `/plan`, +compound-engineering's `ce-*`); this package references them so one +marketplace install wires up everything `hive doctor` checks. + +## Install (Claude Code) + +```sh +claude plugin marketplace add /hive-skills +claude plugin install hive-skills@hive +``` + +## Install (no marketplace mechanism) + +Agents that only support copy-paste: point them at the upstream skill +repos listed in `marketplace.json` and follow each repo's manual install +instructions. `hive doctor` is the preflight that tells you what's still +missing. + +## Versioning + +Versioned and released independently of the hive CLI (the `skills/` +subtree is published on each marketplace release). `hive doctor` reports +per-skill presence so a version skew between CLI and skills surfaces as a +missing-skill row, not a runtime failure. diff --git a/skills/marketplace.json b/skills/marketplace.json new file mode 100644 index 000000000..576211aeb --- /dev/null +++ b/skills/marketplace.json @@ -0,0 +1,23 @@ +{ + "name": "hive-skills", + "description": "Agent skills for the hive folder-as-agent pipeline: planning (/plan), brainstorming (ce-brainstorm), code review (ce-code-review) and the reviewer set hive's 6-review stage dispatches.", + "version": "0.1.0", + "homepage": "https://github.com/ivankuznetsov/hive", + "license": "MIT", + "owner": { + "name": "ivankuznetsov" + }, + "plugins": [ + { + "name": "hive-skills", + "description": "Skills invoked by hive stage agents (see lib/hive/config.rb DEFAULTS and templates/project_config.yml.erb).", + "source": "./plugins/hive-skills", + "strict": false + } + ], + "upstreams": { + "note": "Skill content is authored upstream; this package wires it into agent marketplaces. Codex/Pi channels are added as their marketplace flows stabilize.", + "plan": "https://github.com/ivankuznetsov/llm-wiki (skill /plan)", + "compound-engineering": "https://github.com/ivankuznetsov/compound-engineering (skills /compound-engineering:ce-*)" + } +} diff --git a/skills/plugins/hive-skills/plugin.json b/skills/plugins/hive-skills/plugin.json new file mode 100644 index 000000000..d2f7f0ce8 --- /dev/null +++ b/skills/plugins/hive-skills/plugin.json @@ -0,0 +1,8 @@ +# The hive-skills plugin payload referenced by skills/marketplace.json. +# Skill bodies are authored upstream (llm-wiki / compound-engineering); +# this manifest wires them into marketplace-based agents. +--- +name: hive-skills +description: Skills for hive's stage agents — planning, brainstorming, and the reviewer set. +version: 0.1.0 +skills: [] diff --git a/templates/dev.hive.daemon.plist.erb b/templates/dev.hive.daemon.plist.erb new file mode 100644 index 000000000..a05ab6e76 --- /dev/null +++ b/templates/dev.hive.daemon.plist.erb @@ -0,0 +1,24 @@ + + + + + Label + dev.hive.daemon + ProgramArguments + + <%= hive_bin %> + daemon + start + + RunAtLoad + + KeepAlive + + StandardOutPath + <%= log_file %> + StandardErrorPath + <%= log_file %> + ProcessType + Background + + diff --git a/templates/hive.service.erb b/templates/hive.service.erb new file mode 100644 index 000000000..1f0445ecb --- /dev/null +++ b/templates/hive.service.erb @@ -0,0 +1,16 @@ +[Unit] +Description=Hive daemon (auto-advances tasks through the pipeline) +Documentation=https://github.com/ivankuznetsov/hive +After=network.target + +[Service] +Type=simple +ExecStart=<%= hive_bin %> daemon start +Restart=on-failure +RestartSec=10 +# The daemon polls `hive status --json` per registered project; give it a +# generous stop grace so in-flight stage agents can finish. +TimeoutStopSec=600 + +[Install] +WantedBy=default.target diff --git a/test/test_helper.rb b/test/test_helper.rb index d251d52bf..461e5eb10 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,6 +16,10 @@ module HiveTestStdinIsolation def before_setup @hive_original_stdin = $stdin $stdin = StringIO.new + # Service registration (packaging plan U6) must never fire from tests: + # `hive init` integration runs would otherwise write a real launchd / + # systemd user unit into the developer's HOME on TTY-less machines. + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = "1" super end diff --git a/test/unit/commands/doctor_test.rb b/test/unit/commands/doctor_test.rb index e54f4fd5f..2b354b8e3 100644 --- a/test/unit/commands/doctor_test.rb +++ b/test/unit/commands/doctor_test.rb @@ -142,10 +142,12 @@ class HiveCommandsDoctorTest < Minitest::Test env = JSON.parse(out.string) assert_equal "hive-doctor.v1", env["schema"] - assert_equal 2, env["checks"].length + # 2 stage rows + 6 install-dependency rows (U10 install-awareness). + assert_equal 8, env["checks"].length assert_equal 1, env["summary"]["missing"] - assert_equal 1, env["summary"]["present"] - assert(env["checks"].any? { |c| c["stage"] == "plan" && c["status"] == "present" }) + # present: 1 skill row + however many dependency probes resolve on + # this machine — assert the skill row via stage filter instead. + assert_equal 1, env["checks"].count { |c| c["stage"] == "plan" && c["status"] == "present" } assert(env["checks"].any? { |c| c["stage"] == "brainstorm" && c["status"] == "missing" }) end end @@ -497,7 +499,8 @@ class HiveCommandsDoctorTest < Minitest::Test env = JSON.parse(out.string) assert_equal "hive-doctor.v1", env["schema"] - assert_equal 3, env["checks"].length + # 2 stage + 1 reviewer + 6 dependency rows (U10 install-awareness). + assert_equal 9, env["checks"].length stage_entries = env["checks"].select { |c| c["kind"] == "stage" } reviewer_entries = env["checks"].select { |c| c["kind"] == "reviewer" } diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb new file mode 100644 index 000000000..ec53dc496 --- /dev/null +++ b/test/unit/commands/uninstall_test.rb @@ -0,0 +1,300 @@ +require "test_helper" +require "hive/commands/uninstall" +require "hive/service" + +# U8 of the packaging plan: `hive uninstall` — clean removal that can +# never destroy user work. Project state is opt-in only; --purge in +# non-TTY never deletes .hive-state; double-uninstall is idempotent. +class UninstallTest < Minitest::Test + include HiveTestHelper + + FakeStatus = Class.new do + def initialize(success) = @success = success + def success? = @success + end + + # Probe commands (pacman -Qo / sh command -v) report "not found" so + # channel detection stays deterministic; everything else "succeeds". + def ok_runner + ->(argv) do + probe = argv.first == "pacman" || argv.first == "sh" + [ "", "", FakeStatus.new(!probe) ] + end + end + + # Real subprocess runner — needed by cleanup tests so git actually + # removes worktrees/branches on disk. + def real_runner + ->(argv) { Open3.capture3(*argv) } + end + + def non_tty_stdin + StringIO.new + end + + def tty_stdin(content = "") + io = StringIO.new(content) + def io.tty? = true + io + end + + def with_registered_project + with_tmp_git_repo do |dir| + with_tmp_global_config do |_home| + Hive::Config.register_project(name: File.basename(dir), path: dir) + yield dir + end + end + end + + # ── default: project work is preserved ──────────────────────────────── + + def test_default_uninstall_preserves_worktree_branch_and_logs + with_registered_project do |dir| + # Simulate a completed task: state worktree + branch + logs exist. + state = File.join(dir, ".hive-state") + FileUtils.mkdir_p(File.join(state, "logs")) + File.write(File.join(state, "logs", "task.log"), "output\n") + run!("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/hive/state") + run!("git", "-C", dir, "commit", "--allow-empty", "-m", "state", "--quiet") + run!("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/master") + run!("git", "-C", dir, "worktree", "add", "--quiet", ".hive-state", "hive/state") rescue nil + + out = StringIO.new + result = Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin + ).call + + assert_equal 0, result + assert File.directory?(state), "non-TTY uninstall must never delete project state" + assert File.exist?(File.join(state, "logs", "task.log")) + refute_empty run!("git", "-C", dir, "branch", "--list", "hive/state").strip, + "hive/state branch must survive default uninstall" + # Registry entry untouched (project still registered). + refute_empty Hive::Config.registered_projects + end + end + + # ── opt-in cleanup removes exactly the project's hive content ───────── + + def test_opt_in_cleanup_removes_registered_project_hive_content + with_registered_project do |dir| + state = File.join(dir, ".hive-state") + FileUtils.mkdir_p(state) + + out = StringIO.new + result = Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, runner: real_runner, + cleanup_projects: [ File.basename(dir) ] # explicit opt-in + ).call + + assert_equal 0, result + refute File.exist?(state), "opted-in project's .hive-state must be removed" + assert_empty run!("git", "-C", dir, "branch", "--list", "hive/state").strip, + "hive/state branch must be deleted on opt-in cleanup" + assert_empty Hive::Config.registered_projects, + "registry entry must be dropped on opt-in cleanup" + assert File.exist?(File.join(dir, "README.md")), "project files must survive" + end + end + + def test_opt_in_cleanup_is_scoped_to_named_projects_only + with_registered_project do |dir| + # A second registered project that must stay untouched. + other = File.expand_path(File.join(dir, "..", "other-project")) + FileUtils.mkdir_p(File.join(dir, ".hive-state")) + FileUtils.mkdir_p(File.join(other, ".hive-state")) + Hive::Config.register_project(name: "other-project", path: other) + + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, runner: real_runner, + cleanup_projects: [ "other-project" ] + ).call + + assert File.directory?(File.join(dir, ".hive-state")), + "non-opted project's state must survive" + refute File.directory?(File.join(other, ".hive-state")), + "opted project's state must be removed" + end + end + + # ── --purge non-TTY never deletes project state ─────────────────────── + + def test_purge_non_tty_does_not_delete_state + with_registered_project do |dir| + state = File.join(dir, ".hive-state") + FileUtils.mkdir_p(state) + + out = StringIO.new + result = Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, purge: true + ).call + + assert_equal 0, result + assert File.directory?(state), + "--purge in non-TTY must not delete project state (purge is non-interactivity, not deletion)" + end + end + + # ── interactive prompts ─────────────────────────────────────────────── + + def test_interactive_prompt_default_answer_declines_cleanup + with_registered_project do |dir| + state = File.join(dir, ".hive-state") + FileUtils.mkdir_p(state) + + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: tty_stdin("\n") # bare Enter → default N + ).call + + assert File.directory?(state), "default prompt answer must decline cleanup" + end + end + + def test_interactive_prompt_yes_removes_state + with_registered_project do |dir| + state = File.join(dir, ".hive-state") + FileUtils.mkdir_p(state) + + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: tty_stdin("y\n"), runner: real_runner + ).call + + refute File.directory?(state), "explicit 'y' must remove opted-in project state" + end + end + + # ── channel layer ───────────────────────────────────────────────────── + + def test_brew_channel_prints_exact_native_removal_line + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/opt/homebrew/Cellar/hive/0.1.0/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin + ).call + + assert_includes out.string, "brew uninstall hive" + end + + def test_pacman_channel_prints_exact_native_removal_line + pacman = ->(argv) { argv; [ "", "", FakeStatus.new(true) ] } + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/bin/hive", runner: pacman, + output: out, input: non_tty_stdin + ).call + + assert_includes out.string, "sudo pacman -Rns hive-bin" + end + + def test_script_channel_removes_bundle_and_symlinks + with_tmp_dir do |home| + old_home = ENV["HOME"] + ENV["HOME"] = home + begin + version_dir = File.join(home, ".local", "opt", "hive", "0.1.0") + bin_dir = File.join(version_dir, "bin") + FileUtils.mkdir_p(bin_dir) + File.write(File.join(version_dir, Hive::Channel::BUNDLE_MARKER), "0.1.0") + File.write(File.join(bin_dir, "hive"), "#!/bin/sh\n") + + local_bin = File.join(home, ".local", "bin") + FileUtils.mkdir_p(local_bin) + File.symlink(File.join(bin_dir, "hive"), File.join(local_bin, "hive")) + File.symlink(File.join(bin_dir, "hive"), File.join(local_bin, "hv")) + # A symlink pointing elsewhere must survive. + File.symlink("/usr/bin/env", File.join(local_bin, "keepme")) + + out = StringIO.new + result = Hive::Commands::Uninstall.new( + bin_path: File.join(bin_dir, "hive"), + runner: ->(argv) { argv; [ "", "", FakeStatus.new(false) ] }, + output: out, input: non_tty_stdin + ).call + + assert_equal 0, result + refute File.exist?(version_dir), "script bundle must be removed" + refute File.symlink?(File.join(local_bin, "hive")), "hive symlink must be removed" + refute File.symlink?(File.join(local_bin, "hv")), "hv fallback symlink must be removed" + assert File.symlink?(File.join(local_bin, "keepme")), "unrelated symlinks must survive" + ensure + ENV["HOME"] = old_home + end + end + end + + # ── idempotency ─────────────────────────────────────────────────────── + + def test_double_uninstall_is_idempotent + out1 = StringIO.new + out2 = StringIO.new + runner = ->(argv) { argv; [ "", "", FakeStatus.new(false) ] } + + 2.times do |i| + result = Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: runner, + output: i.zero? ? out1 : out2, input: non_tty_stdin + ).call + assert_equal 0, result, "uninstall must be safely re-runnable" + end + end + + # ── service layer is always attempted ───────────────────────────────── + + def test_service_registration_removal_is_attempted + calls = [] + fake_service = Class.new do + c = calls + define_singleton_method(:skipped?) { false } + define_singleton_method(:remove!) { |**kw| c << kw; true } + end + + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, service: fake_service + ).call + + assert_equal 1, calls.size, "uninstall must always attempt service removal" + end + + def test_service_removal_failure_is_non_fatal + fake_service = Class.new do + define_singleton_method(:skipped?) { false } + define_singleton_method(:remove!) { |**_kw| raise "backend exploded" } + end + + out = StringIO.new + result = Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, service: fake_service + ).call + + assert_equal 0, result, "service-removal failure must not abort uninstall" + assert_includes out.string, "warning" + end + + def test_skip_flag_skips_service_removal + fake_service = Class.new do + define_singleton_method(:skipped?) { true } + define_singleton_method(:remove!) { |**_kw| raise "must not be called" } + end + + out = StringIO.new + Hive::Commands::Uninstall.new( + bin_path: "/usr/local/bin/hive", runner: ok_runner, + output: out, input: non_tty_stdin, service: fake_service + ).call + + assert true # no raise — removal skipped + end +end diff --git a/test/unit/commands/update_test.rb b/test/unit/commands/update_test.rb new file mode 100644 index 000000000..966af55bc --- /dev/null +++ b/test/unit/commands/update_test.rb @@ -0,0 +1,191 @@ +require "test_helper" +require "hive/channel" +require "hive/commands/update" + +# U7 of the packaging plan: `hive update` upgrades through the channel +# that installed hive, never swapping binaries itself. +class UpdateTest < Minitest::Test + include HiveTestHelper + + FakeStatus = Class.new do + def initialize(success) = @success = success + def success? = @success + end + + def recording_runner(expected: nil) + commands = [] + runner = lambda do |argv| + commands << argv + # Probe-style commands (pacman -Qo, command -v) report "not found" + # so channel detection stays deterministic unless a test overrides. + probe = argv.first == "pacman" || argv.first == "sh" + [ "", "", FakeStatus.new(!probe) ] + end + [ commands, runner ] + end + + def failing_runner + ->(argv) { [ "", "nope", FakeStatus.new(false) ] } + end + + # ── channel detection ───────────────────────────────────────────────── + + def test_detect_brew_via_cellar_path + assert_equal :brew, Hive::Channel.detect( + bin_path: "/opt/homebrew/Cellar/hive/0.1.0/bin/hive", + runner: ->(argv) { raise "pacman must not run for brew paths" } + ) + end + + def test_detect_pacman_via_pacman_qo + pacman = ->(argv) { argv; [ "/extra/hive-bin 0.1.0-1\n", "", FakeStatus.new(true) ] } + assert_equal :pacman, Hive::Channel.detect(bin_path: "/usr/bin/hive", runner: pacman) + end + + def test_detect_script_via_bundle_marker + with_tmp_dir do |dir| + bundle = File.join(dir, ".local", "opt", "hive", "0.1.0", "bin") + FileUtils.mkdir_p(bundle) + File.write(File.join(File.dirname(bundle), Hive::Channel::BUNDLE_MARKER), "0.1.0") + File.write(File.join(bundle, "hive"), "#!/bin/sh\n") + + assert_equal :script, Hive::Channel.detect( + bin_path: File.join(bundle, "hive"), + runner: ->(argv) { argv; [ "", "", FakeStatus.new(false) ] } + ) + end + end + + def test_detect_unknown_when_nothing_matches + assert_equal :unknown, Hive::Channel.detect( + bin_path: "/usr/local/bin/hive", + runner: ->(argv) { argv; [ "", "", FakeStatus.new(false) ] } + ) + end + + def test_detect_refuses_ambiguous_channels + with_tmp_dir do |dir| + # A binary under a tmp-local "/cellar/" path that ALSO carries a + # bundle marker AND is pacman-owned → refuse to guess. + weird = File.join(dir, "opt", "homebrew", "Cellar", "hive", "0.1.0", "bin", "hive") + marker = File.join(dir, "opt", "homebrew", "Cellar", "hive", "0.1.0", Hive::Channel::BUNDLE_MARKER) + FileUtils.mkdir_p(File.dirname(weird)) + File.write(marker, "0.1.0") + File.write(weird, "#!/bin/sh\n") + pacman = ->(argv) { argv; [ "", "", FakeStatus.new(true) ] } + + assert_raises(Hive::Channel::AmbiguousChannel) do + Hive::Channel.detect(bin_path: weird, runner: pacman) + end + end + end + + # ── upgrade commands per channel ────────────────────────────────────── + + def test_upgrade_command_brew + assert_equal %w[brew upgrade hive], Hive::Channel.upgrade_command(:brew) + end + + def test_upgrade_command_script_reruns_installer + command = Hive::Channel.upgrade_command(:script, repo: "acme/hive") + assert_equal "bash", command[0] + assert_includes command[2], "https://github.com/acme/hive/releases/latest/download/install.sh" + end + + def test_update_runs_native_command_for_brew_channel + commands, runner = recording_runner + out = StringIO.new + result = Hive::Commands::Update.new( + bin_path: "/opt/homebrew/Cellar/hive/0.1.0/bin/hive", + runner: runner, output: out + ).call + + assert_equal 0, result + upgrades = commands.reject { |argv| argv.first == "pacman" } # drop detect probes + assert_equal [ %w[brew upgrade hive] ], upgrades, + "brew installs must upgrade via brew, never by copying binaries" + assert_includes out.string, "running: brew upgrade hive" + end + + def test_update_dry_run_prints_without_executing + commands, runner = recording_runner + out = StringIO.new + result = Hive::Commands::Update.new( + bin_path: "/opt/homebrew/Cellar/hive/0.1.0/bin/hive", + runner: runner, output: out, dry_run: true + ).call + + assert_equal 0, result + executed = commands.reject { |argv| argv.first == "pacman" } # drop detect probes + assert_empty executed, "--dry-run must not execute anything" + assert_includes out.string, "brew upgrade hive" + end + + def test_update_uses_yay_when_available_for_pacman + commands = [] + runner = lambda do |argv| + commands << argv + if argv.first == "sh" + # command -v yay probe → yay exists + [ "/usr/bin/yay\n", "", FakeStatus.new(true) ] + else + [ "", "", FakeStatus.new(true) ] + end + end + out = StringIO.new + result = Hive::Commands::Update.new( + bin_path: "/usr/bin/hive", + runner: runner, output: out + ).call + + assert_equal 0, result + assert_includes commands, %w[yay -Syu hive-bin] + end + + def test_update_prints_manual_hint_when_no_yay_and_never_runs_sudo + commands = [] + runner = lambda do |argv| + commands << argv + if argv == %w[pacman] - [] || argv.first == "pacman" + [ "/extra/hive-bin 0.1.0-1\n", "", FakeStatus.new(true) ] # pacman owns it + elsif argv.first == "sh" + [ "", "", FakeStatus.new(false) ] # no yay on PATH + else + raise "must not run anything beyond probes without yay" + end + end + out = StringIO.new + result = Hive::Commands::Update.new( + bin_path: "/usr/bin/hive", + runner: runner, output: out + ).call + + assert_equal 1, result + assert_includes out.string, "sudo pacman -Syu" + assert_includes out.string, "never runs sudo" + forbidden = commands.reject { |argv| argv.first == "sh" || argv.first == "pacman" } + assert_empty forbidden, + "without yay, update must only probe — never run sudo/yay itself" + end + + def test_update_unknown_channel_prints_manual_instructions + out = StringIO.new + result = Hive::Commands::Update.new( + bin_path: "/somewhere/random/bin/hive", + runner: ->(argv) { argv; [ "", "", FakeStatus.new(false) ] }, + output: out + ).call + + assert_equal 1, result + assert_includes out.string, "could not determine" + end + + def test_update_surfaces_failing_native_command + assert_raises(Hive::Error) do + Hive::Commands::Update.new( + bin_path: "/opt/homebrew/Cellar/hive/0.1.0/bin/hive", + runner: failing_runner, output: StringIO.new + ).call + end + end +end diff --git a/test/unit/config_xdg_test.rb b/test/unit/config_xdg_test.rb new file mode 100644 index 000000000..f7f7d737d --- /dev/null +++ b/test/unit/config_xdg_test.rb @@ -0,0 +1,236 @@ +require "test_helper" +require "hive/config" + +# U2 of the packaging plan: XDG layout migration for hive's GLOBAL files. +# Per-project state (.hive-state/) is untouched by design — these tests +# only cover path resolution for config / state / cache homes. +class ConfigXDGTest < Minitest::Test + include HiveTestHelper + + XDG_ENV_KEYS = %w[ + HIVE_HOME HIVE_CONFIG_HOME HIVE_STATE_HOME HIVE_CACHE_HOME + XDG_CONFIG_HOME XDG_STATE_HOME XDG_CACHE_HOME + ].freeze + + def with_clean_env(env = {}) + old = {} + XDG_ENV_KEYS.each { |k| old[k] = ENV[k]; ENV.delete(k) } + env.each { |k, v| ENV[k] = v } + Hive::Config.migration_notice_printed = false + begin + yield + ensure + XDG_ENV_KEYS.each do |k| + if old[k].nil? + ENV.delete(k) + else + ENV[k] = old[k] + end + end + Hive::Config.migration_notice_printed = false + end + end + + # ── config_home resolution ──────────────────────────────────────────── + + def test_config_home_defaults_to_xdg + with_clean_env do + assert_equal File.join(File.expand_path("~"), ".config/hive"), Hive::Config.config_home + end + end + + def test_config_home_honours_xdg_config_home + with_clean_env("XDG_CONFIG_HOME" => "/xdg/cfg") do + assert_equal "/xdg/cfg/hive", Hive::Config.config_home + end + end + + def test_config_home_honours_hive_config_home_over_everything + with_clean_env("XDG_CONFIG_HOME" => "/xdg/cfg", "HIVE_CONFIG_HOME" => "/custom") do + assert_equal "/custom", Hive::Config.config_home + end + end + + def test_hive_home_keeps_dev_checkout_mode + with_tmp_dir do |dir| + with_clean_env("HIVE_HOME" => dir) do + assert Hive::Config.legacy_mode? + assert_equal dir, Hive::Config.config_home + assert_equal File.join(dir, "config.yml"), Hive::Config.global_config_path + end + end + end + + def test_global_config_path_defaults_to_xdg_location + with_clean_env do + assert_equal File.join(File.expand_path("~"), ".config/hive/config.yml"), + Hive::Config.global_config_path + end + end + + def test_legacy_config_used_as_fallback_when_xdg_absent + with_tmp_dir do |dir| + legacy = File.join(dir, "Dev", "hive") + FileUtils.mkdir_p(legacy) + File.write(File.join(legacy, "config.yml"), { "registered_projects" => [] }.to_yaml) + + with_clean_env("HOME" => dir) do + # HOME is not in XDG_ENV_KEYS; set it manually for the duration. + old_home = ENV["HOME"] + ENV["HOME"] = dir + begin + Hive::Config.migration_notice_printed = false + assert_equal File.join(legacy, "config.yml"), Hive::Config.global_config_path + ensure + ENV["HOME"] = old_home + end + end + end + end + + def test_xdg_config_wins_over_legacy_when_present + with_tmp_dir do |dir| + xdg = File.join(dir, ".config", "hive") + legacy = File.join(dir, "Dev", "hive") + FileUtils.mkdir_p(xdg) + FileUtils.mkdir_p(legacy) + File.write(File.join(xdg, "config.yml"), { "registered_projects" => [] }.to_yaml) + File.write(File.join(legacy, "config.yml"), { "registered_projects" => [] }.to_yaml) + + old_home = ENV["HOME"] + ENV["HOME"] = dir + begin + with_clean_env do + assert_equal File.join(xdg, "config.yml"), Hive::Config.global_config_path + end + ensure + ENV["HOME"] = old_home + end + end + end + + # ── state_home / cache_home ─────────────────────────────────────────── + + def test_state_home_defaults_to_xdg_state + with_clean_env do + assert_equal File.join(File.expand_path("~"), ".local/state/hive"), Hive::Config.state_home + end + end + + def test_state_home_honours_xdg_state_home + with_clean_env("XDG_STATE_HOME" => "/xdg/state") do + assert_equal "/xdg/state/hive", Hive::Config.state_home + end + end + + def test_state_home_honours_hive_state_home + with_clean_env("HIVE_STATE_HOME" => "/custom/state") do + assert_equal "/custom/state/hive", Hive::Config.state_home + end + end + + def test_state_home_stays_in_hive_home_in_dev_mode + with_tmp_dir do |dir| + with_clean_env("HIVE_HOME" => dir) do + assert_equal dir, Hive::Config.state_home + end + end + end + + def test_cache_home_defaults_to_xdg_cache + with_clean_env do + assert_equal File.join(File.expand_path("~"), ".cache/hive"), Hive::Config.cache_home + end + end + + # ── daemon pid/log placement ────────────────────────────────────────── + + def test_daemon_pid_and_log_live_in_state_home_in_xdg_mode + with_clean_env("HIVE_STATE_HOME" => "/xdg/state") do + daemon = Hive::Commands::Daemon.new("status") + assert_equal "/xdg/state/hive/daemon.pid", daemon.pid_file + assert_equal "/xdg/state/hive/logs/daemon.log", daemon.log_file + end + end + + def test_daemon_pid_and_log_stay_in_hive_home_in_dev_mode + with_tmp_dir do |dir| + with_clean_env("HIVE_HOME" => dir) do + daemon = Hive::Commands::Daemon.new("status", hive_home: dir) + assert_equal File.join(dir, ".daemon.pid"), daemon.pid_file + assert_equal File.join(dir, "logs", "daemon.log"), daemon.log_file + end + end + end + + # ── bot defaults follow the state home ──────────────────────────────── + + def test_global_bot_defaults_use_state_home + with_clean_env("HIVE_STATE_HOME" => "/xdg/state") do + defaults = Hive::Config.global_bot_defaults + assert_equal "/xdg/state/hive/.bot.pid", defaults["pid_file"] + assert_equal "/xdg/state/hive/logs/bot.log", defaults["log_file"] + end + end + + def test_global_bot_defaults_use_hive_home_in_dev_mode + with_tmp_dir do |dir| + with_clean_env("HIVE_HOME" => dir) do + defaults = Hive::Config.global_bot_defaults + assert_equal File.join(dir, ".bot.pid"), defaults["pid_file"] + assert_equal File.join(dir, "logs", "bot.log"), defaults["log_file"] + end + end + end + + # ── migration notice ────────────────────────────────────────────────── + + def test_migration_notice_printed_once_for_legacy_fallback + with_tmp_dir do |dir| + legacy = File.join(dir, "Dev", "hive") + FileUtils.mkdir_p(legacy) + File.write(File.join(legacy, "config.yml"), { "registered_projects" => [] }.to_yaml) + + old_home = ENV["HOME"] + old_stderr = $stderr + ENV["HOME"] = dir + $stderr = StringIO.new + begin + with_clean_env do + Hive::Config.migration_notice_printed = false + Hive::Config.global_config_path + Hive::Config.global_config_path # second call — no repeat notice + out = $stderr.string + assert_includes out, "legacy global config" + assert_equal 1, out.scan("legacy global config").size, + "migration notice must print exactly once per process" + end + ensure + ENV["HOME"] = old_home + $stderr = old_stderr + end + end + end + + # ── registry round-trip through the XDG path ────────────────────────── + + def test_register_project_writes_to_xdg_config_home + with_tmp_dir do |dir| + old_home = ENV["HOME"] + ENV["HOME"] = dir + begin + with_clean_env("HIVE_CONFIG_HOME" => File.join(dir, "cfg")) do + entry = Hive::Config.register_project(name: "demo", path: dir) + assert_equal "demo", entry["name"] + cfg_path = File.join(dir, "cfg", "config.yml") + assert File.exist?(cfg_path), "register_project must create the XDG config" + data = YAML.safe_load(File.read(cfg_path)) + assert_equal [ "demo" ], data["registered_projects"].map { |p| p["name"] } + assert_equal [ entry ], Hive::Config.registered_projects + end + ensure + ENV["HOME"] = old_home + end + end + end +end diff --git a/test/unit/doctor_dependencies_test.rb b/test/unit/doctor_dependencies_test.rb new file mode 100644 index 000000000..d49dc6290 --- /dev/null +++ b/test/unit/doctor_dependencies_test.rb @@ -0,0 +1,131 @@ +require "test_helper" +require "stringio" +require "json" +require "hive/commands/doctor" + +# U10 of the packaging plan: install-aware `hive doctor` — required +# (git, bash) vs recommended (claude, gh, jq, tmux) dependency reporting +# with actionable per-OS install hints and distinct exit codes. +class DoctorDependenciesTest < Minitest::Test + include HiveTestHelper + + def base_config + { + "brainstorm" => { "agent" => "claude" }, + "plan" => { "agent" => "claude" } + } + end + + def probe(all_present: true) + Hive::Commands::Doctor::REQUIRED_TOOLS.product( + Hive::Commands::Doctor::RECOMMENDED_TOOLS + ) + tools = Hive::Commands::Doctor::REQUIRED_TOOLS + Hive::Commands::Doctor::RECOMMENDED_TOOLS + tools.to_h { |t| [ t, all_present ] } + end + + def run_doctor(probes, strict: false) + out = StringIO.new + doctor = Hive::Commands::Doctor.new( + config: base_config, project_root: nil, + json: true, output: out, strict: strict, probe_results: probes + ) + # Skip tmux/skill checks by giving them injectable-friendly config: + # skill verification still probes the fake home; keep it simple by + # stubbing nothing — instead read rows directly. + exit_code = doctor.call + [ exit_code, JSON.parse(out.string), doctor ] + end + + def with_green_skills + with_fake_home do |home| + write_file("#{home}/.claude/plugins/cache/mp/compound-engineering/3.0.1/skills/ce-brainstorm/SKILL.md") + write_file("#{home}/.claude/commands/plan.md") + yield home + end + end + + def test_healthy_machine_exits_zero_with_all_dependencies_present + with_green_skills do + code, env = run_doctor(probe(all_present: true)) + assert_equal 0, code + assert_equal 0, env["summary"]["missing_required"] + assert_equal 0, env["summary"]["missing_recommended"] + end + end + + def test_missing_required_tool_exits_66 + with_green_skills do + probes = probe(all_present: true) + probes["git"] = false + code, env = run_doctor(probes) + assert_equal Hive::Commands::Doctor::EXIT_MISSING_REQUIRED, code + assert_equal 1, env["summary"]["missing_required"] + row = env["checks"].find { |c| c["skill"] == "git" } + assert_equal "missing_required", row["status"] + assert_equal true, row["required"] + assert_includes row["message"], "REQUIRED" + end + end + + def test_missing_recommended_tool_prints_hint_but_exits_zero_by_default + with_green_skills do + probes = probe(all_present: true) + probes["gh"] = false + probes["jq"] = false + code, env = run_doctor(probes) + assert_equal 0, code, "recommended-tool misses must not fail a default run" + assert_equal 2, env["summary"]["missing_recommended"] + row = env["checks"].find { |c| c["skill"] == "gh" } + assert_equal "missing_recommended", row["status"] + assert_equal false, row["required"] + assert_includes row["message"], "install gh" + end + end + + def test_strict_mode_escalates_recommended_misses_to_67 + with_green_skills do + probes = probe(all_present: true) + probes["tmux"] = false + code, _env = run_doctor(probes, strict: true) + assert_equal Hive::Commands::Doctor::EXIT_MISSING_RECOMMENDED, code + end + end + + def test_required_miss_takes_precedence_over_skill_misses + with_fake_home do |_home| + # No skills installed at all → skill rows are missing (65-class); + # plus bash gone → required miss. Exit must be 66. + probes = probe(all_present: true) + probes["bash"] = false + code, _env = run_doctor(probes) + assert_equal Hive::Commands::Doctor::EXIT_MISSING_REQUIRED, code + end + end + + def test_install_hints_are_per_os_family + doctor = Hive::Commands::Doctor.new(config: {}, project_root: nil) + hint = doctor.send(:install_hint, "git") + assert_kind_of String, hint + refute_empty hint + end + + private + + def with_fake_home(&block) + with_tmp_dir do |dir| + old = ENV["HOME"] + ENV["HOME"] = dir + begin + yield dir + ensure + old.nil? ? ENV.delete("HOME") : ENV["HOME"] = old + end + end + end + + def write_file(path, content = "") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end +end diff --git a/test/unit/init_service_test.rb b/test/unit/init_service_test.rb new file mode 100644 index 000000000..eb88b87bf --- /dev/null +++ b/test/unit/init_service_test.rb @@ -0,0 +1,142 @@ +require "test_helper" +require "hive/commands/init" + +# U6: `hive init` offers one identical daemon-service prompt on both +# platforms; non-TTY defaults to register-only; the skip flag prevents +# registration entirely; backend failures never fail the init. +class InitServiceTest < Minitest::Test + include HiveTestHelper + + # Injectable fake for Init's `service:` collaborator. Records every + # install! call so tests can assert enable_and_start semantics. + class FakeService + attr_reader :install_calls + + def initialize(platform:, skipped: false, installed: false, raise_on_install: false) + @platform = platform + @skipped = skipped + @installed = installed + @raise_on_install = raise_on_install + @install_calls = [] + end + + def skipped? = @skipped + def platform = @platform + def installed?(platform:) = @installed + + def install!(**kw) + raise "boom" if @raise_on_install + + @install_calls << kw + "/fake/unit/path" + end + end + + def run_registration(dir, service) + out, err = with_captured_exit do + Hive::Commands::Init.new(dir, prompts: non_tty_prompts, service: service).maybe_register_service + end + [ out, err ] + end + + def test_non_tty_init_registers_service_without_enabling + with_tmp_dir do |dir| + svc = FakeService.new(platform: :systemd) + capture_io { run_registration(dir, svc) } + + assert_equal 1, svc.install_calls.size, "non-TTY must still register the unit (register-only)" + assert_equal false, svc.install_calls.first[:enable_and_start], + "non-TTY must NEVER autostart the daemon" + end + end + + def test_tty_yes_answer_enables_and_starts + with_tmp_dir do |dir| + svc = FakeService.new(platform: :systemd) + old_stdin = $stdin + $stdin = tty_string_io("y\n") + begin + capture_io { run_registration(dir, svc) } + ensure + $stdin = old_stdin + end + + assert_equal 1, svc.install_calls.size + assert_equal true, svc.install_calls.first[:enable_and_start] + end + end + + def test_tty_no_answer_registers_only + with_tmp_dir do |dir| + svc = FakeService.new(platform: :systemd) + old_stdin = $stdin + $stdin = tty_string_io("n\n") + begin + capture_io { run_registration(dir, svc) } + ensure + $stdin = old_stdin + end + + assert_equal 1, svc.install_calls.size + assert_equal false, svc.install_calls.first[:enable_and_start], "decline path = register-only" + end + end + + def test_none_platform_skips_entirely + with_tmp_dir do |dir| + svc = FakeService.new(platform: :none) + capture_io { run_registration(dir, svc) } + + assert_empty svc.install_calls, ":none platform must not touch the service layer" + end + end + + def test_already_installed_skips_reinstall + with_tmp_dir do |dir| + svc = FakeService.new(platform: :systemd, installed: true) + capture_io { run_registration(dir, svc) } + + assert_empty svc.install_calls, "init must be idempotent when a unit already exists" + end + end + + def test_skip_flag_prevents_registration + with_tmp_dir do |dir| + old = ENV["HIVE_SKIP_SERVICE_REGISTRATION"] + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = "1" + begin + svc = FakeService.new(platform: :systemd) + capture_io do + # Production path (no injected service) must honour the flag. + Hive::Commands::Init.new(dir, prompts: non_tty_prompts).maybe_register_service + end + assert_empty svc.install_calls + ensure + old.nil? ? ENV.delete("HIVE_SKIP_SERVICE_REGISTRATION") : ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = old + end + end + end + + def test_backend_failure_is_swallowed + with_tmp_dir do |dir| + svc = FakeService.new(platform: :systemd, raise_on_install: true) + _out, err = capture_io { run_registration(dir, svc) } + # The point of this test: no exception escapes init. + assert true + end + end + + private + + def non_tty_prompts + # Init#maybe_register_service never touches the prompts object; pass a + # minimal non-nil stand-in so construction stays cheap. + Hive::Commands::Init::Prompts.allocate + end + + def tty_string_io(content) + io = StringIO.new(content) + def io.tty? = true + io + end +end diff --git a/test/unit/packaging/install_sh_test.rb b/test/unit/packaging/install_sh_test.rb new file mode 100644 index 000000000..5f4c21cd6 --- /dev/null +++ b/test/unit/packaging/install_sh_test.rb @@ -0,0 +1,195 @@ +require "test_helper" +require "digest" +require "open3" +require "tmpdir" +require "fileutils" + +# U3 of the packaging plan: end-to-end runs of packaging/install.sh in a +# sandboxed HOME with a stubbed `curl`, covering fresh install, +# idempotent upgrade, the Apache-Hive `hv` collision fallback, hard +# dependency failures, and checksum verification. +class InstallShTest < Minitest::Test + include HiveTestHelper + + REPO_ROOT = File.expand_path("../../..", __dir__) + INSTALL_SH = File.join(REPO_ROOT, "packaging", "install.sh").freeze + VERSION = "0.1.0".freeze + + def setup_sandbox + Dir.mktmpdir("hive-install-test") do |home| + @home = home + @prefix = File.join(home, ".local") + @bin_dir = File.join(@prefix, "bin") + @fake_bin = File.join(home, "fake-bin") + FileUtils.mkdir_p([ @fake_bin, @bin_dir ]) + build_fake_release + build_fake_curl + yield home + end + end + + # Builds a fake release payload: tarball + SHA256SUMS + latest-release + # JSON, all served by the fake curl below. + def build_fake_release + platform = RUBY_PLATFORM.include?("darwin") ? "macos-arm64" : "linux-x86_64-gnu" + name = "hive-#{VERSION}-#{platform}" + bundle = File.join(@fake_bin, "payload", name) + FileUtils.mkdir_p(bundle) + File.write(File.join(bundle, ".hive-bundle"), "#{VERSION}\n") + File.write(File.join(bundle, "hive"), <<~SH) + #!/bin/bash + echo "hive #{VERSION}" + SH + File.chmod(0o755, File.join(bundle, "hive")) + + tarball = File.join(@fake_bin, "payload", "#{name}.tar.gz") + system("tar", "-czf", tarball, "-C", File.dirname(bundle), name) || raise("tar failed") + @tarball_name = "#{name}.tar.gz" + @sha = Digest::SHA256.file(tarball).hexdigest + write_checksums(@sha) + File.write(File.join(@fake_bin, "payload", "latest.json"), '{"tag_name": "v' + VERSION + '"}') + end + + def write_checksums(sha) + File.write(File.join(@fake_bin, "payload", "SHA256SUMS"), "#{sha} #{@tarball_name}\n") + end + + def build_fake_curl + File.write(File.join(@fake_bin, "curl"), <<~SH) + #!/bin/bash + # Minimal curl stub: handles -o and treats the last arg as URL. + # Streams files directly — no command substitution, so binary payloads + # (the tarball) survive byte-for-byte. + out="" + args=("$@") + for i in "${!args[@]}"; do + if [[ "${args[$i]}" == "-o" ]]; then + out="${args[$((i+1))]}" + fi + done + url="${args[-1]}" + case "$url" in + */releases/latest) + src="#{@fake_bin}/payload/latest.json" ;; + *SHA256SUMS) + src="#{@fake_bin}/payload/SHA256SUMS" ;; + *#{@tarball_name}) + src="#{@fake_bin}/payload/#{@tarball_name}" ;; + *) + echo "fake-curl: unexpected url $url" >&2; exit 22 ;; + esac + if [[ -n "$out" ]]; then + cat "$src" > "$out" + else + cat "$src" + fi + SH + File.chmod(0o755, File.join(@fake_bin, "curl")) + end + + # A PATH dir mirroring the real system bins minus named tools, so + # `command -v ` deterministically fails while everything else + # (uname, sed, grep, mktemp...) still works. + def stripped_bin_dir(without:) + dir = File.join(@home, "stripped-bin") + FileUtils.mkdir_p(dir) + [ "/usr/bin", "/bin" ].each do |src| + next unless File.directory?(src) + + Dir.children(src).each do |entry| + next if without.include?(entry) + target = File.join(dir, entry) + next if File.exist?(target) + + File.symlink(File.join(src, entry), target) + end + end + dir + end + + def run_installer(env: {}) + # Base PATH strips any pre-existing `hive` binary (this container has + # Apache Hive on PATH!) so the collision logic is deterministic. + # Computed lazily so an explicit PATH override (missing-git test) never + # populates the shared stripped dir with excluded tools. + full_env = { "HOME" => @home, "HIVE_PREFIX" => @prefix }.merge(env) + full_env["PATH"] ||= "#{@fake_bin}:#{stripped_bin_dir(without: %w[hive])}" + Open3.capture3(full_env, "bash", INSTALL_SH) + end + + # ── scenarios ─────────────────────────────────────────────────────────── + + def test_fresh_install_creates_bundle_symlink_and_verifies_version + setup_sandbox do + out, err, status = run_installer + + assert status.success?, "installer failed:\n#{out}\n#{err}" + assert_equal "#{VERSION}\n", File.read(File.join(@prefix, "opt/hive/#{VERSION}/.hive-bundle")) + assert File.exist?(File.join(@prefix, "opt/hive/current/hive")) + assert File.symlink?(File.join(@bin_dir, "hive")) + assert_includes out, "verification passed" + end + end + + def test_rerun_upgrades_in_place_idempotently + setup_sandbox do + _out1, _err1, s1 = run_installer + assert s1.success? + out2, err2, s2 = run_installer + assert s2.success?, "second run must be an in-place upgrade:\n#{out2}\n#{err2}" + entries = Dir.children(File.join(@prefix, "opt/hive")).sort + assert_equal [ VERSION, "current" ], entries, "upgrade must not litter version dirs" + end + end + + def test_hive_name_collision_installs_as_hv_and_warns_loudly + setup_sandbox do + # A foreign 'hive' earlier on PATH (e.g., Apache Hive). + File.write(File.join(@fake_bin, "hive"), "#!/bin/bash\necho apache-hive\n") + File.chmod(0o755, File.join(@fake_bin, "hive")) + + out, _err, status = run_installer(env: { "PATH" => "#{@fake_bin}:#{stripped_bin_dir(without: [])}" }) + + assert status.success? + assert_includes out, "hv", "collision must be announced loudly" + refute File.symlink?(File.join(@bin_dir, "hive")), "must NOT clobber the existing hive command" + assert File.symlink?(File.join(@bin_dir, "hv")), "fallback launcher must be installed as hv" + + link_target = File.readlink(File.join(@bin_dir, "hv")) + assert_includes link_target, "opt/hive/current/hive" + end + end + + def test_missing_git_fails_with_hint_naming_package_manager_command + setup_sandbox do + stripped = stripped_bin_dir(without: %w[git]) + + _out, err, status = run_installer(env: { "PATH" => "#{@fake_bin}:#{stripped}" }) + + refute status.success?, "missing required tool must hard-fail" + assert_match(/missing required tool 'git'/, err) + assert_match(/apt-get|pacman|brew/, err, "failure must name a package-manager hint") + refute File.symlink?(File.join(@bin_dir, "hive")), "nothing may be installed on dependency failure" + end + end + + def test_checksum_mismatch_refuses_install + setup_sandbox do + write_checksums(Digest::SHA256.hexdigest("bogus")) + + _out, err, status = run_installer + + refute status.success? + assert_match(/checksum verification FAILED/i, err) + refute File.symlink?(File.join(@bin_dir, "hive")), "nothing may be installed on checksum failure" + end + end + + def test_pinned_version_env_is_honoured + setup_sandbox do + _out, _err, status = run_installer(env: { "HIVE_VERSION" => "v0.1.0" }) + assert status.success? + assert File.directory?(File.join(@prefix, "opt/hive/0.1.0")) + end + end +end diff --git a/test/unit/packaging/packaging_static_test.rb b/test/unit/packaging/packaging_static_test.rb new file mode 100644 index 000000000..b4646cdca --- /dev/null +++ b/test/unit/packaging/packaging_static_test.rb @@ -0,0 +1,84 @@ +require "test_helper" +require "open3" +require "yaml" + +# Packaging sanity gates (U1/U3/U4/U5): shell scripts parse, the release +# workflow is valid YAML with the expected matrix, the PKGBUILD carries the +# required fields, and the bundle marker name matches what Hive::Channel +# detects. These are static checks — the functional installer tests live in +# test/unit/packaging/install_sh_test.rb. +class PackagingStaticTest < Minitest::Test + include HiveTestHelper + + REPO_ROOT = File.expand_path("../../..", __dir__) + + def shell_scripts + [ + "packaging/install.sh", + "packaging/build-bundle.sh", + "packaging/aur/generate-srcinfo.sh" + ].map { |rel| File.join(REPO_ROOT, rel) } + end + + def test_all_shell_scripts_parse + shell_scripts.each do |path| + assert File.exist?(path), "#{path} must exist" + out, err, status = Open3.capture3("bash", "-n", path) + assert status.success?, "#{path} has bash syntax errors:\n#{err}" + end + end + + def test_shell_scripts_are_executable + shell_scripts.each do |path| + assert File.executable?(path), "#{path} must be executable" + end + end + + def test_release_workflow_is_valid_yaml_with_expected_matrix + path = File.join(REPO_ROOT, ".github", "workflows", "release.yml") + doc = YAML.safe_load(File.read(path), aliases: true) + + assert_equal "release", doc["name"] + # YAML 1.1 parses a bare `on:` key as boolean true — handle both. + trigger = doc["on"] || doc[true] + assert_includes trigger.dig("push", "tags"), "v*" + + matrix = doc.dig("jobs", "build", "strategy", "matrix", "include") + platforms = matrix.map { |job| job["platform"] }.sort + assert_equal %w[linux-x86_64-gnu macos-arm64 macos-x86_64], platforms + + # Oldest-glibc rule: linux artifacts must build on ubuntu-22.04. + linux_job = matrix.find { |job| job["platform"] == "linux-x86_64-gnu" } + assert_equal "ubuntu-22.04", linux_job["os"], + "linux bundles must build on the oldest tier-1 runner for glibc compat" + end + + def test_pkgbuild_carries_required_fields + path = File.join(REPO_ROOT, "packaging", "aur", "PKGBUILD") + content = File.read(path) + + assert_includes content, "pkgname=hive-bin" + assert_includes content, "provides=('hive')" + assert_includes content, "conflicts=('hive')" + assert_includes content, "arch=('x86_64')" + assert_includes content, "linux-x86_64-gnu" + end + + def test_bundle_marker_matches_channel_detection + assert_equal Hive::Channel::BUNDLE_MARKER, ".hive-bundle" + # build-bundle.sh writes the marker the channel detector looks for. + builder = File.read(File.join(REPO_ROOT, "packaging", "build-bundle.sh")) + assert_includes builder, Hive::Channel::BUNDLE_MARKER + end + + def test_homebrew_formula_is_valid_ruby_and_vendors_no_ruby_dep + path = File.join(REPO_ROOT, "packaging", "homebrew", "Formula", "hive.rb") + content = File.read(path) + # ruby -c via the syntax check in a subprocess (formula references + # Homebrew constants we don't have here, so full load is impossible). + out, err, status = Open3.capture3("ruby", "-c", path) + assert status.success?, "formula has Ruby syntax errors:\n#{err}" + refute_match(/depends_on "ruby"/, content, + "formula must NOT depend on system ruby — the bundle vendors it") + end +end diff --git a/test/unit/service_test.rb b/test/unit/service_test.rb new file mode 100644 index 000000000..e636f1a87 --- /dev/null +++ b/test/unit/service_test.rb @@ -0,0 +1,273 @@ +require "test_helper" +require "hive/service" +require "open3" + +# U6 of the packaging plan: OS service registration (launchd on macOS, +# systemd --user on Linux) with stubbed backends — no real launchctl / +# systemctl is ever invoked here. +class ServiceTest < Minitest::Test + include HiveTestHelper + + class FakeStatus + def initialize(success) + @success = success + end + + def success? + @success + end + end + + def ok_status = FakeStatus.new(true) + def failing_status = FakeStatus.new(false) + + def runner_recording(recording, status: ok_status) + ->(argv) { recording << argv; [ "", "", status ] } + end + + # ── platform detection ──────────────────────────────────────────────── + + def test_platform_is_none_or_a_known_backend + # The test container is Linux without systemd, but don't hard-fail on + # developer machines — just assert the closed set. + assert_includes [ :launchd, :systemd, :none ], Hive::Service.platform + end + + # ── unit paths ──────────────────────────────────────────────────────── + + def test_unit_paths_per_platform + assert_equal "/Users/x/Library/LaunchAgents/dev.hive.daemon.plist", + Hive::Service.unit_path(platform: :launchd, home: "/Users/x") + assert_equal "/home/x/.config/systemd/user/hive.service", + Hive::Service.unit_path(platform: :systemd, home: "/home/x") + assert_nil Hive::Service.unit_path(platform: :none) + end + + def test_installed_is_false_when_no_unit_file + with_tmp_dir do |dir| + refute Hive::Service.installed?(platform: :systemd, home: dir) + end + end + + def test_installed_is_true_when_unit_file_exists + with_tmp_dir do |dir| + path = Hive::Service.unit_path(platform: :systemd, home: dir) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + assert Hive::Service.installed?(platform: :systemd, home: dir) + end + end + + # ── rendering ───────────────────────────────────────────────────────── + + def test_render_systemd_unit_references_hive_bin_and_is_valid_shape + unit = Hive::Service.render_unit(platform: :systemd, hive_bin: "/usr/local/bin/hive", + log_file: "/tmp/daemon.log") + assert_includes unit, "ExecStart=/usr/local/bin/hive daemon start" + assert_includes unit, "WantedBy=default.target" + assert_includes unit, "[Install]" + end + + def test_render_launchd_plist_is_valid_xml_with_label + require "rexml/document" + plist = Hive::Service.render_unit(platform: :launchd, hive_bin: "/opt/hive/bin/hive", + log_file: "/tmp/daemon.log") + doc = REXML::Document.new(plist) + labels = doc.get_elements("//string").map(&:text) + assert_includes labels, "dev.hive.daemon" + assert_includes labels, "/opt/hive/bin/hive" + assert_includes labels, "daemon" + assert_includes labels, "start" + end + + # ── install! ────────────────────────────────────────────────────────── + + def test_install_systemd_writes_unit_and_enables_when_asked + with_tmp_dir do |home| + commands = [] + out = StringIO.new + path = Hive::Service.install!( + enable_and_start: true, platform: :systemd, home: home, + hive_bin: "/usr/local/bin/hive", log_file: "/tmp/d.log", + runner: runner_recording(commands), output: out + ) + + assert_equal File.join(home, ".config/systemd/user/hive.service"), path + assert File.exist?(path) + assert_equal [ + %w[systemctl --user daemon-reload], + %w[systemctl --user enable --now hive.service] + ], commands + assert_includes out.string, "registered + started" + end + end + + def test_install_systemd_register_only_does_not_enable + with_tmp_dir do |home| + commands = [] + out = StringIO.new + Hive::Service.install!( + enable_and_start: false, platform: :systemd, home: home, + hive_bin: "/usr/local/bin/hive", log_file: "/tmp/d.log", + runner: runner_recording(commands), output: out + ) + + assert_equal [ %w[systemctl --user daemon-reload] ], commands + assert_includes out.string, "registered (not enabled)" + end + end + + def test_install_launchd_writes_plist_and_loads_when_enabled + with_tmp_dir do |home| + commands = [] + out = StringIO.new + path = Hive::Service.install!( + enable_and_start: true, platform: :launchd, home: home, + hive_bin: "/opt/hive/bin/hive", log_file: "/tmp/d.log", + runner: runner_recording(commands), output: out + ) + + assert_equal File.join(home, "Library/LaunchAgents/dev.hive.daemon.plist"), path + assert_equal [ + %w[launchctl unload -w] + [path], + %w[launchctl load -w] + [path] + ], commands + end + end + + def test_install_is_idempotent_overwrites_unit_in_place + with_tmp_dir do |home| + runner = runner_recording([]) + args = { enable_and_start: false, platform: :systemd, home: home, + hive_bin: "/usr/local/bin/hive", log_file: "/tmp/d.log", + runner: runner, output: StringIO.new } + Hive::Service.install!(**args) + Hive::Service.install!(**args) + assert File.exist?(File.join(home, ".config/systemd/user/hive.service")) + end + end + + def test_install_none_platform_is_a_noop + assert_nil Hive::Service.install!(enable_and_start: true, platform: :none, + runner: ->(_argv) { raise "must not run" }) + end + + def test_install_backend_failure_warns_but_keeps_unit_file + with_tmp_dir do |home| + out = StringIO.new + failing = ->(argv) { argv; [ "", "boom", failing_status ] } + path = Hive::Service.install!( + enable_and_start: true, platform: :systemd, home: home, + hive_bin: "/usr/local/bin/hive", log_file: "/tmp/d.log", + runner: failing, output: out + ) + assert File.exist?(path), "unit file must survive backend failure" + assert_includes out.string, "warning" + assert_includes out.string, "enable manually" + end + end + + # ── remove! ─────────────────────────────────────────────────────────── + + def test_remove_systemd_disables_then_deletes_unit + with_tmp_dir do |home| + path = File.join(home, ".config/systemd/user/hive.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + + commands = [] + removed = Hive::Service.remove!(platform: :systemd, home: home, + runner: runner_recording(commands), output: StringIO.new) + + assert removed + refute File.exist?(path) + assert_includes commands, %w[systemctl --user disable --now hive.service] + end + end + + def test_remove_is_idempotent_when_nothing_installed + with_tmp_dir do |home| + refute Hive::Service.remove!(platform: :systemd, home: home, output: StringIO.new) + end + end + + def test_remove_survives_backend_failure + with_tmp_dir do |home| + path = File.join(home, ".config/systemd/user/hive.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + failing = ->(argv) { argv; [ "", "", failing_status ] } + removed = Hive::Service.remove!(platform: :systemd, home: home, + runner: failing, output: StringIO.new) + assert removed, "file removal is the authoritative step; backend failure must not block it" + refute File.exist?(path) + end + end + + # ── daemon delegation ───────────────────────────────────────────────── + + def test_running_systemd_true_only_when_active + with_tmp_dir do |home| + path = File.join(home, ".config/systemd/user/hive.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + + active = ->(argv) { argv; [ "active\n", "", ok_status ] } + inactive = ->(argv) { argv; [ "inactive\n", "", ok_status ] } + + assert Hive::Service.running?(platform: :systemd, home: home, runner: active) + refute Hive::Service.running?(platform: :systemd, home: home, runner: inactive) + end + end + + def test_running_false_when_not_installed + with_tmp_dir do |home| + refute Hive::Service.running?(platform: :launchd, runner: ->(argv) { argv; [ "", "", ok_status ] }) + end + end + + def test_start_and_stop_delegate_to_service_manager + with_tmp_dir do |home| + path = File.join(home, ".config/systemd/user/hive.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + commands = [] + runner = runner_recording(commands) + + assert Hive::Service.start!(platform: :systemd, home: home, runner: runner) + assert Hive::Service.stop!(platform: :systemd, home: home, runner: runner) + assert_includes commands, %w[systemctl --user start hive.service] + assert_includes commands, %w[systemctl --user stop hive.service] + end + end + + def test_start_raises_service_error_on_failure + with_tmp_dir do |home| + path = File.join(home, ".config/systemd/user/hive.service") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "[Unit]\n") + failing = ->(argv) { argv; [ "", "unit not found", failing_status ] } + assert_raises(Hive::Service::ServiceError) do + Hive::Service.start!(platform: :systemd, home: home, runner: failing) + end + end + end + + # ── skip flag ───────────────────────────────────────────────────────── + + def test_skipped_flag_honoured + old = ENV["HIVE_SKIP_SERVICE_REGISTRATION"] + begin + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = "1" + assert Hive::Service.skipped? + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = "0" + refute Hive::Service.skipped? + ensure + if old.nil? + ENV.delete("HIVE_SKIP_SERVICE_REGISTRATION") + else + ENV["HIVE_SKIP_SERVICE_REGISTRATION"] = old + end + end + end +end